diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7109bf5c..1f2bce90 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,7 +8,7 @@ permissions: contents: write env: - TAURI_CONF: Ether/.html/src-tauri/tauri.conf.json + TAURI_CONF: @ether/.html/src-tauri/tauri.conf.json jobs: # --------------------------------------------------------------------------- @@ -22,7 +22,7 @@ jobs: steps: - uses: actions/checkout@v4 with: - sparse-checkout: Ether/.html/src-tauri/tauri.conf.json + sparse-checkout: @ether/.html/src-tauri/tauri.conf.json sparse-checkout-cone-mode: false - name: Extract version & check tag @@ -64,11 +64,11 @@ jobs: run: pip install pillow - name: npm ci - working-directory: Ether/.html + working-directory: @ether/.html run: npm ci - name: Build Linux - working-directory: Ether/.html + working-directory: @ether/.html run: npx tauri build --target x86_64-unknown-linux-gnu - name: Upload Linux artifacts @@ -76,9 +76,9 @@ jobs: with: name: linux-artifacts path: | - Ether/.html/src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb - Ether/.html/src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm - Ether/.html/src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage + @ether/.html/src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb + @ether/.html/src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm + @ether/.html/src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage # --------------------------------------------------------------------------- # Windows (cross-compile from Ubuntu) @@ -109,11 +109,11 @@ jobs: run: pip install pillow - name: npm ci - working-directory: Ether/.html + working-directory: @ether/.html run: npm ci - name: Build Windows - working-directory: Ether/.html + working-directory: @ether/.html run: npx tauri build --target x86_64-pc-windows-gnu - name: Upload Windows artifacts @@ -121,7 +121,7 @@ jobs: with: name: windows-artifacts path: | - Ether/.html/src-tauri/target/x86_64-pc-windows-gnu/release/bundle/nsis/*.exe + @ether/.html/src-tauri/target/x86_64-pc-windows-gnu/release/bundle/nsis/*.exe # --------------------------------------------------------------------------- # Android (APK + AAB) @@ -160,11 +160,11 @@ jobs: run: pip install pillow - name: npm ci - working-directory: Ether/.html + working-directory: @ether/.html run: npm ci - name: Build Android - working-directory: Ether/.html + working-directory: @ether/.html env: NDK_HOME: ${{ env.ANDROID_HOME }}/ndk/27.0.12077973 run: | @@ -177,8 +177,8 @@ jobs: ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} run: | - APK_DIR="Ether/.html/src-tauri/gen/android/app/build/outputs/apk/universal/release" - AAB_DIR="Ether/.html/src-tauri/gen/android/app/build/outputs/bundle/universalRelease" + APK_DIR="@ether/.html/src-tauri/gen/android/app/build/outputs/apk/universal/release" + AAB_DIR="@ether/.html/src-tauri/gen/android/app/build/outputs/bundle/universalRelease" UNSIGNED="$APK_DIR/app-universal-release-unsigned.apk" # Rename APK @@ -203,8 +203,8 @@ jobs: with: name: android-artifacts path: | - Ether/.html/src-tauri/gen/android/app/build/outputs/apk/universal/release/Ether_*.apk - Ether/.html/src-tauri/gen/android/app/build/outputs/bundle/universalRelease/Ether_*.aab + @ether/.html/src-tauri/gen/android/app/build/outputs/apk/universal/release/Ether_*.apk + @ether/.html/src-tauri/gen/android/app/build/outputs/bundle/universalRelease/Ether_*.aab # --------------------------------------------------------------------------- # macOS (Apple Silicon) — uncomment when mac runner is available @@ -226,11 +226,11 @@ jobs: run: pip3 install --break-system-packages pillow - name: npm ci - working-directory: Ether/.html + working-directory: @ether/.html run: npm ci - name: Build macOS (dmg) - working-directory: Ether/.html + working-directory: @ether/.html run: npx tauri build --target aarch64-apple-darwin - name: Upload macOS artifacts @@ -238,7 +238,7 @@ jobs: with: name: macos-artifacts path: | - Ether/.html/src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/*.dmg + @ether/.html/src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/*.dmg # --------------------------------------------------------------------------- # iOS — uncomment when mac runner is available @@ -281,12 +281,12 @@ jobs: # # - name: npm ci # if: steps.version.outputs.skip == 'false' - # working-directory: Ether/.html + # working-directory: @ether/.html # run: npm ci # # - name: Build iOS (ipa) # if: steps.version.outputs.skip == 'false' - # working-directory: Ether/.html + # working-directory: @ether/.html # run: | # npx tauri ios init # npx tauri ios build --export-method debugging @@ -297,7 +297,7 @@ jobs: # with: # name: ios-artifacts # path: | - # Ether/.html/src-tauri/gen/apple/build/**/*.ipa + # @ether/.html/src-tauri/gen/apple/build/**/*.ipa # --------------------------------------------------------------------------- # Create GitHub Release with all artifacts diff --git a/Ether/%/NEXT-UUID-OF-OBJECT.ray.txt b/@ether/%/NEXT-UUID-OF-OBJECT.ray.txt similarity index 100% rename from Ether/%/NEXT-UUID-OF-OBJECT.ray.txt rename to @ether/%/NEXT-UUID-OF-OBJECT.ray.txt diff --git a/Ether/%/ORIGINAL-UUID-OF-OBJECT.ray.txt b/@ether/%/ORIGINAL-UUID-OF-OBJECT.ray similarity index 100% rename from Ether/%/ORIGINAL-UUID-OF-OBJECT.ray.txt rename to @ether/%/ORIGINAL-UUID-OF-OBJECT.ray diff --git a/Ether/.html/.nvmrc b/@ether/.html/.nvmrc similarity index 100% rename from Ether/.html/.nvmrc rename to @ether/.html/.nvmrc diff --git a/@ether/.html/TODO.md b/@ether/.html/TODO.md new file mode 100644 index 00000000..1c5ee5ad --- /dev/null +++ b/@ether/.html/TODO.md @@ -0,0 +1,37 @@ +- Upload files +- dropdown of branches +- .. +- Access permissions for which file shown to whom, for .ray is also content of the file +- On editing access permission: prompt with should you change the history access as well? +- Files can inside them have a index.ray.js, to decide how to render them +- Have multiple versions open at the same time.\ +- Clones by default go under @ether/<> , how in the interface to say I want @ether @ @ether version is @local + - Version @local is reserved for this. usernames as version are reserved: The @local is relative to you, the @ is the version by someone else under the same name. or @username/other_path similarly, @username versions are reserved. +- fork should disappear the /~/@me when not @ether, any @/# is reserved and cannot be cloned to. But the original path is fine @ether/library for instance. +- Namespaces of versions. so latest has branches main, ..., and again @me has branches main.. / @me~main ; version of the repository vs branch version + +Networking, hosting as a peer is opt-in, but using others is done by default, has to be turned off. For function execution on ether servers is agnostic cloud - choose which one default to the calculated cheapest/what according to preferences; redundancy etc.? ; Need to be able to separately turn on peer hosting vs hosting a function endpoint. Might want to do that per object or top-level, so Online turns on @public, need separate keyword for through proxy, so only through @ether for instance. @proxy.@public @proxy being @ether by default. Another setting needs to be turned on to "act as server"; opt-in for the hosting as peer. Allow chaining of that @, to say traffic should be routed like that. Or the folder is hosted under me, but not part of the Index, so @Public works on where it is hosted; so that you dont have to mark @proxy everywhere. Basically instead: "Start hosting this file/endpoints from your local machine" instead. + +- PR comments should use chat infrastructure. + + +- after encrypt delete history of unencrypted prompt + + +- allow disabling of the UI overlay for index.ray.js + + +- Chat goes in @ether/@/ +- Chat has text information but also 3d info later. +- Chat has index.ray.js on how to render the chat (shadowed/inherited from main chat repo) +- Group gets created in /@ or @ether/#{UUID} (can be given a separate #/@ name by reserving it) then a @me version of that chat. +- All the @me versions should be indexed separately in @me + + +- shadowed files should be low opacity. + + +- For the iframes they should come with the caller character, which is/isnt allowed to access the certain player information. It's not @local but a different guest or so. + + +- More intelligent path mapping when the number of files is very large automatically \ No newline at end of file diff --git a/@ether/.html/UI/API.ts b/@ether/.html/UI/API.ts new file mode 100644 index 00000000..f148c462 --- /dev/null +++ b/@ether/.html/UI/API.ts @@ -0,0 +1,171 @@ +// ============================================================ +// API.ts — Centralized data access facade +// ============================================================ +// All consumers import from here instead of DummyData.ts directly. +// +// Every data access wrapper is async and routes through getAPI(), +// which auto-detects the backend (Dummy, Tauri, or Http). + +// ---- A) Type re-exports ---- +export type { FileEntry, CompoundEntry, TreeEntry, Repository } from './DummyData.ts'; +export type { PullRequest, PRStatus, FileDiff, PRCommit, PRComment, ActivityItem, InlinePR, CategoryPRSummary } from './DummyData.ts'; + +// ---- B) Pure utility re-exports (never become API calls) ---- +export { isCompound, flattenEntries, resolveDirectory, resolveFile, resolveFiles } from './DummyData.ts'; + +// ---- C) EtherAPI — the async data access layer ---- +export { getAPI, setAPI } from './EtherAPI.ts'; +export type { EtherAPI } from './EtherAPI.ts'; + +// ---- D) Async data access wrappers (route through getAPI()) ---- + +import { getAPI } from './EtherAPI.ts'; +import type { Repository, InlinePR, CategoryPRSummary, PullRequest } from './DummyData.ts'; + +export async function getRepository(user: string): Promise { + return getAPI().getRepository(user); +} + +export async function getWorld(user: string, world: string): Promise { + return getAPI().getWorld(user, world); +} + +export async function getReferencedUsers(user: string, world?: string | null): Promise { + const users = await getAPI().getReferencedUsers(user, world); + // At the top level (no world context), always include @ether so /@ether is navigable + if (!world && !users.includes('ether')) users.push('ether'); + return users; +} + +export async function getReferencedWorlds(user: string, world?: string | null): Promise { + return getAPI().getReferencedWorlds(user, world); +} + +export async function getOpenPRCount(canonicalPath: string): Promise { + return getAPI().getOpenPRCount(canonicalPath); +} + +export async function getInlinePullRequests(canonicalPath: string): Promise { + return getAPI().getInlinePullRequests(canonicalPath); +} + +export async function getCategoryPRSummary(path: string, prefix: '~' | '@'): Promise { + return getAPI().getCategoryPRSummary(path, prefix); +} + +export async function getCategoryPullRequests(path: string, prefix: '~' | '@'): Promise { + return getAPI().getCategoryPullRequests(path, prefix); +} + +export async function getPullRequest(path: string, id: number): Promise { + return getAPI().getPullRequest(path, id); +} + +export async function createPullRequest( + canonicalPath: string, + title: string, + description: string, + sourceLabel: string, + targetLabel: string, + author?: string, +): Promise { + return getAPI().createPullRequest(canonicalPath, title, description, sourceLabel, targetLabel, author || getCurrentPlayer()); +} + +// ---- E) LocalStorage state ---- + +// -- Player identity -- + +export function getCurrentPlayer(): string { + return localStorage.getItem('ether:name') || 'anonymous'; +} + +export function getDefaultUser(): string { + return getCurrentPlayer(); +} + +// -- Stars -- + +const STARS_KEY = 'ether:stars'; + +function setStars(stars: string[]): void { + localStorage.setItem(STARS_KEY, stars.join('\n')); +} + +export function getStars(): string[] { + const raw = localStorage.getItem(STARS_KEY); + return raw ? raw.split('\n').filter(Boolean) : []; +} + +export function getStarCount(canonicalPath: string): number { + const raw = localStorage.getItem(`ether:star-count:${canonicalPath}`); + return raw ? parseInt(raw, 10) || 0 : 0; +} + +export function setStarCount(canonicalPath: string, count: number): void { + localStorage.setItem(`ether:star-count:${canonicalPath}`, String(Math.max(0, count))); +} + +export function isStarred(canonicalPath: string): boolean { + const stars = getStars(); + if (stars.includes(canonicalPath)) return true; + // Parent match — but NOT for worlds (#), players (@), or top-level libraries + const parts = canonicalPath.split('/'); + for (let i = parts.length - 1; i >= 1; i--) { + const parent = parts.slice(0, i).join('/'); + const child = parts[i]; + // Stop cascade at world/player/top-level-library boundaries + if (child.startsWith('@') || child.startsWith('#') || child.startsWith('~')) break; + // First real directory after @user or @user/#world = top-level library, needs own star + if (i === 1 || parts[i - 1].startsWith('@') || parts[i - 1].startsWith('#') || parts[i - 1].startsWith('~')) break; + if (stars.includes(parent)) return true; + } + return false; +} + +export function toggleStar(canonicalPath: string): boolean { + const stars = getStars(); + const idx = stars.indexOf(canonicalPath); + if (idx >= 0) { + stars.splice(idx, 1); + setStars(stars); + return false; + } else { + stars.push(canonicalPath); + setStars(stars); + return true; + } +} + +// -- Forks -- + +export function getForkCount(canonicalPath: string): number { + const raw = localStorage.getItem(`ether:fork-count:${canonicalPath}`); + return raw ? parseInt(raw, 10) || 0 : 0; +} + +export function setForkCount(canonicalPath: string, count: number): void { + localStorage.setItem(`ether:fork-count:${canonicalPath}`, String(Math.max(0, count))); +} + +// -- Sessions -- + +function sessionKey(user: string): string { + return `ether:session:${user}`; +} + +export function loadSession(user: string): Record { + try { + const raw = localStorage.getItem(sessionKey(user)); + return raw ? JSON.parse(raw) : {}; + } catch { return {}; } +} + +export function saveSession(user: string, data: Record): void { + localStorage.setItem(sessionKey(user), JSON.stringify(data, null, 2)); +} + +export function getSessionContent(user: string): string { + const session = loadSession(user); + return JSON.stringify(session, null, 2); +} diff --git a/@ether/.html/UI/CRTShell.ts b/@ether/.html/UI/CRTShell.ts new file mode 100644 index 00000000..c6cacadb --- /dev/null +++ b/@ether/.html/UI/CRTShell.ts @@ -0,0 +1,374 @@ +// ============================================================ +// CRTShell.ts — Reusable CRT monitor shell: DOM, styles, animations +// No dependencies. Pure DOM + CSS. +// ============================================================ + +export const PHOSPHOR = '#ffffff'; +export const CRT_SCREEN_BG = '#0a0a0a'; + +// ---- Utilities ---- + +export function delay(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +export async function fadeOutElement(el: HTMLElement): Promise { + el.classList.add('fade-out'); + await delay(450); + el.remove(); +} + +// ---- CRT Interface ---- + +export interface CRT { + crt: HTMLElement; + screen: HTMLElement; + scanlines: HTMLElement; + vignette: HTMLElement; + terminal: HTMLElement; + content: HTMLElement; +} + +// ---- Style Injection ---- + +export function injectCRTStyles(): HTMLStyleElement { + const s = document.createElement('style'); + s.textContent = ` + * { margin: 0; padding: 0; box-sizing: border-box; } + body { + background: #000; + overflow: hidden; + font-family: 'Courier New', Courier, monospace; + } + + /* ---- CRT Shell ---- */ + .crt { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: #050505; + } + + .crt-screen { + position: relative; + width: 100%; + height: 100%; + background: ${CRT_SCREEN_BG}; + border-radius: 14px; + overflow: hidden; + opacity: 0; + } + + /* Scanlines */ + .crt-scanlines { + position: absolute; + inset: 0; + background: repeating-linear-gradient( + 0deg, + rgba(0, 0, 0, 0.06) 0px, + rgba(0, 0, 0, 0.06) 1px, + transparent 1px, + transparent 3px + ); + pointer-events: none; + z-index: 100; + transition: opacity 1.5s ease-out; + } + + /* Vignette for CRT curvature illusion */ + .crt-vignette { + position: absolute; + inset: 0; + background: radial-gradient( + ellipse at center, + transparent 55%, + rgba(0, 0, 0, 0.55) 100% + ); + pointer-events: none; + z-index: 101; + transition: opacity 1.5s ease-out; + } + + /* Steady-state flicker + ambient glow */ + @keyframes flicker { + 0% { opacity: 0.985; } + 4% { opacity: 0.965; } + 8% { opacity: 0.995; } + 12% { opacity: 0.975; } + 50% { opacity: 0.98; } + 100% { opacity: 0.985; } + } + @keyframes glow { + 0%, 100% { + box-shadow: 0 0 30px rgba(255,255,255,0.04), + inset 0 0 80px rgba(0,0,0,0.4); + } + 50% { + box-shadow: 0 0 50px rgba(255,255,255,0.08), + inset 0 0 80px rgba(0,0,0,0.4); + } + } + .crt-screen.on { + opacity: 1; + animation: flicker 0.13s infinite, glow 5s ease-in-out infinite; + } + + /* Turn-on keyframes (applied via JS style.animation) */ + @keyframes turnOn { + 0% { + clip-path: inset(50% 50% 50% 50%); + filter: brightness(10) saturate(0); + } + 8% { + clip-path: inset(49.7% 20% 49.7% 20%); + filter: brightness(8) saturate(0); + } + 22% { + clip-path: inset(49% 2% 49% 2%); + filter: brightness(4) saturate(0.2); + } + 48% { + clip-path: inset(12% 0% 12% 0%); + filter: brightness(2) saturate(0.6); + } + 72% { + clip-path: inset(2% 0% 2% 0%); + filter: brightness(1.35) saturate(0.9); + } + 90% { + clip-path: inset(0% 0% 0% 0%); + filter: brightness(1.1) saturate(1); + } + 100% { + clip-path: inset(0% 0% 0% 0%); + filter: brightness(1) saturate(1); + } + } + + /* ---- Terminal ---- */ + .terminal { + position: relative; + z-index: 50; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + overflow: visible; + } + + .terminal-content { + display: flex; + flex-direction: column; + align-items: center; + max-width: 90%; + overflow: visible; + } + + /* Text with phosphor glow + subtle chromatic aberration */ + .t { + color: ${PHOSPHOR}; + text-shadow: + 0 0 4px rgba(255,255,255,0.5), + 0 0 11px rgba(255,255,255,0.22), + -0.4px 0 rgba(255,80,80,0.07), + 0.4px 0 rgba(80,80,255,0.07); + } + + .t-large { + font-size: 52px; + letter-spacing: 14px; + font-weight: bold; + } + + .t-muted { + color: rgba(255,255,255,0.35); + text-shadow: none; + } + + /* Cursor */ + @keyframes cursorPulse { + 0%, 49% { opacity: 1; } + 50%, 100% { opacity: 0; } + } + .cursor { + display: inline-block; + width: 0.55em; + height: 1em; + background: ${PHOSPHOR}; + box-shadow: 0 0 8px rgba(255,255,255,0.5); + position: relative; + top: 0.22em; + animation: cursorPulse 1s step-end infinite; + } + + /* Fade animations */ + @keyframes fadeOut { to { opacity: 0; } } + @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } + .fade-out { animation: fadeOut 0.4s ease-out forwards; } + .fade-in { animation: fadeIn 0.6s ease-in forwards; } + + /* ---- Input ---- */ + .input-row { + display: flex; + align-items: baseline; + font-size: 18px; + line-height: 1.8; + white-space: pre; + } + + .hint { + margin-top: 14px; + font-size: 13px; + color: rgba(255,255,255,0.3); + } + + .hidden-input { + position: fixed; + left: -9999px; + top: 0; + width: 200px; + height: 40px; + font-size: 16px; + opacity: 0; + border: none; + padding: 0; + } + + /* Glitch shake */ + @keyframes glitchShake { + 0% { transform: translate(0, 0) skewX(0); clip-path: inset(0); } + 8% { transform: translate(-18px, 8px) skewX(-8deg); } + 16% { transform: translate(22px, -6px) skewX(10deg); clip-path: inset(10% 0 30% 0); } + 24% { transform: translate(-14px, -12px) skewX(-5deg); clip-path: inset(0); } + 32% { transform: translate(25px, 5px) skewX(12deg); } + 40% { transform: translate(-20px, 10px) skewX(-10deg); clip-path: inset(40% 0 10% 0); } + 48% { transform: translate(16px, -14px) skewX(7deg); clip-path: inset(0); } + 56% { transform: translate(-24px, 6px) skewX(-12deg); } + 64% { transform: translate(20px, -10px) skewX(8deg); clip-path: inset(20% 0 20% 0); } + 72% { transform: translate(-12px, 12px) skewX(-6deg); clip-path: inset(0); } + 80% { transform: translate(22px, -8px) skewX(10deg); } + 88% { transform: translate(-18px, 7px) skewX(-8deg); clip-path: inset(50% 0 5% 0); } + 100%{ transform: translate(14px, -5px) skewX(6deg); clip-path: inset(0); } + } + .glitch-shake { + animation: glitchShake 0.08s linear infinite; + } + + .ether-logo { + display: block; + } + `; + document.head.appendChild(s); + return s; +} + +// ---- CRT DOM ---- + +export function createCRT(): CRT { + const crt = document.createElement('div'); + crt.className = 'crt'; + + const screen = document.createElement('div'); + screen.className = 'crt-screen'; + + const scanlines = document.createElement('div'); + scanlines.className = 'crt-scanlines'; + + const vignette = document.createElement('div'); + vignette.className = 'crt-vignette'; + + const terminal = document.createElement('div'); + terminal.className = 'terminal'; + + const content = document.createElement('div'); + content.className = 'terminal-content'; + + terminal.appendChild(content); + screen.appendChild(terminal); + screen.appendChild(scanlines); + screen.appendChild(vignette); + crt.appendChild(screen); + document.body.appendChild(crt); + + return { crt, screen, scanlines, vignette, terminal, content }; +} + +// ---- CRT Turn-On ---- + +export async function turnOnScreen(screen: HTMLElement): Promise { + screen.style.opacity = '1'; + screen.style.animation = + 'turnOn 1.6s cubic-bezier(0.22, 1, 0.36, 1) forwards'; + + await delay(1700); + + screen.style.animation = ''; + screen.style.opacity = ''; + screen.style.clipPath = ''; + screen.style.filter = ''; + screen.classList.add('on'); +} + +// ---- CRT Dissolve ---- + +export async function dissolveCRT(crt: CRT): Promise { + const { scanlines, vignette, screen } = crt; + + // Fade out scanlines and vignette + scanlines.style.opacity = '0'; + vignette.style.opacity = '0'; + + // Transition border-radius to 0 + screen.style.transition = 'border-radius 1.5s ease-out, background 1.5s ease-out, box-shadow 1.5s ease-out'; + screen.style.borderRadius = '0'; + + // Stop flicker/glow — override with static state + screen.classList.remove('on'); + screen.style.opacity = '1'; + screen.style.animation = 'none'; + screen.style.boxShadow = 'none'; + + // Unify backgrounds to the screen's center color + screen.style.background = CRT_SCREEN_BG; + crt.crt.style.transition = 'background 1.5s ease-out'; + crt.crt.style.background = CRT_SCREEN_BG; + document.body.style.transition = 'background 1.5s ease-out'; + document.body.style.background = CRT_SCREEN_BG; + + // Wait for transitions to complete + await delay(2000); + + // Clean up scanline/vignette elements + scanlines.remove(); + vignette.remove(); +} + +// ---- Typewriter ---- + +export async function typeText( + parent: HTMLElement, + text: string, + opts: { speed?: number; large?: boolean } = {}, +): Promise { + const span = document.createElement('span'); + span.className = 't'; + if (opts.large) span.classList.add('t-large'); + parent.appendChild(span); + + const cursor = document.createElement('span'); + cursor.className = 'cursor'; + parent.appendChild(cursor); + + const speed = opts.speed ?? 60; + + for (let i = 0; i < text.length; i++) { + span.textContent += text[i]; + await delay(speed + Math.random() * 30 - 10); + } + + cursor.remove(); + return span; +} diff --git a/@ether/.html/UI/DiffView.ts b/@ether/.html/UI/DiffView.ts new file mode 100644 index 00000000..40153510 --- /dev/null +++ b/@ether/.html/UI/DiffView.ts @@ -0,0 +1,149 @@ +// ============================================================ +// DiffView.ts — LCS-based line diff algorithm + HTML rendering +// ============================================================ + +export interface DiffLine { + type: 'add' | 'remove' | 'context'; + oldNum: number | null; + newNum: number | null; + text: string; +} + +/** Compute LCS table for two arrays of lines */ +function lcsTable(a: string[], b: string[]): number[][] { + const m = a.length; + const n = b.length; + const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)); + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + if (a[i - 1] === b[j - 1]) { + dp[i][j] = dp[i - 1][j - 1] + 1; + } else { + dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]); + } + } + } + return dp; +} + +/** Compute line-level diff using LCS backtracking */ +export function computeDiff(oldText: string, newText: string): DiffLine[] { + const oldLines = oldText.split('\n'); + const newLines = newText.split('\n'); + const dp = lcsTable(oldLines, newLines); + + const result: DiffLine[] = []; + let i = oldLines.length; + let j = newLines.length; + + while (i > 0 || j > 0) { + if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) { + result.push({ type: 'context', oldNum: i, newNum: j, text: oldLines[i - 1] }); + i--; + j--; + } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) { + result.push({ type: 'add', oldNum: null, newNum: j, text: newLines[j - 1] }); + j--; + } else { + result.push({ type: 'remove', oldNum: i, newNum: null, text: oldLines[i - 1] }); + i--; + } + } + + return result.reverse(); +} + +function escapeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +/** Render unified diff view as HTML */ +export function renderUnifiedDiff(lines: DiffLine[]): string { + let html = '
'; + for (const line of lines) { + const cls = line.type === 'add' ? 'diff-line-add' : line.type === 'remove' ? 'diff-line-remove' : 'diff-line-context'; + const prefix = line.type === 'add' ? '+' : line.type === 'remove' ? '-' : ' '; + const oldN = line.oldNum !== null ? String(line.oldNum) : ''; + const newN = line.newNum !== null ? String(line.newNum) : ''; + html += `
`; + html += `${oldN}`; + html += `${newN}`; + html += `${prefix}`; + html += `${escapeHtml(line.text)}`; + html += `
`; + } + html += '
'; + return html; +} + +/** Render side-by-side diff view as HTML */ +export function renderSideBySideDiff(lines: DiffLine[]): string { + // Build paired rows: context lines go together; removes pair with adjacent adds + const leftRows: { num: number | null; text: string; type: 'context' | 'remove' | 'empty' }[] = []; + const rightRows: { num: number | null; text: string; type: 'context' | 'add' | 'empty' }[] = []; + + let i = 0; + while (i < lines.length) { + const line = lines[i]; + if (line.type === 'context') { + leftRows.push({ num: line.oldNum, text: line.text, type: 'context' }); + rightRows.push({ num: line.newNum, text: line.text, type: 'context' }); + i++; + } else if (line.type === 'remove') { + // Collect consecutive removes + const removes: DiffLine[] = []; + while (i < lines.length && lines[i].type === 'remove') { + removes.push(lines[i]); + i++; + } + // Collect consecutive adds + const adds: DiffLine[] = []; + while (i < lines.length && lines[i].type === 'add') { + adds.push(lines[i]); + i++; + } + const maxLen = Math.max(removes.length, adds.length); + for (let k = 0; k < maxLen; k++) { + if (k < removes.length) { + leftRows.push({ num: removes[k].oldNum, text: removes[k].text, type: 'remove' }); + } else { + leftRows.push({ num: null, text: '', type: 'empty' }); + } + if (k < adds.length) { + rightRows.push({ num: adds[k].newNum, text: adds[k].text, type: 'add' }); + } else { + rightRows.push({ num: null, text: '', type: 'empty' }); + } + } + } else { + // Standalone add + leftRows.push({ num: null, text: '', type: 'empty' }); + rightRows.push({ num: line.newNum, text: line.text, type: 'add' }); + i++; + } + } + + let html = '
'; + for (let r = 0; r < leftRows.length; r++) { + const left = leftRows[r]; + const right = rightRows[r]; + const leftCls = left.type === 'remove' ? 'diff-line-remove' : left.type === 'empty' ? 'diff-line-empty' : 'diff-line-context'; + const rightCls = right.type === 'add' ? 'diff-line-add' : right.type === 'empty' ? 'diff-line-empty' : 'diff-line-context'; + html += `
`; + html += `
`; + html += `${left.num ?? ''}`; + html += `${escapeHtml(left.text)}`; + html += `
`; + html += `
`; + html += `${right.num ?? ''}`; + html += `${escapeHtml(right.text)}`; + html += `
`; + html += `
`; + } + html += '
'; + return html; +} diff --git a/@ether/.html/UI/DummyData.ts b/@ether/.html/UI/DummyData.ts new file mode 100644 index 00000000..c6aa4b46 --- /dev/null +++ b/@ether/.html/UI/DummyData.ts @@ -0,0 +1,1554 @@ +// ============================================================ +// DummyData.ts — Mock file tree for @ether (player = repository) +// ============================================================ + +export interface FileEntry { + name: string; + isDirectory: boolean; + modified: string; + children?: TreeEntry[]; + content?: string; + access?: 'public' | 'local' | 'private' | 'npc' | 'player' | 'everyone'; + encrypted?: boolean; +} + +export interface CompoundEntry { + op: '&' | '|'; + entries: TreeEntry[]; +} + +export type TreeEntry = FileEntry | CompoundEntry; + +export function isCompound(entry: TreeEntry): entry is CompoundEntry { + return 'op' in entry; +} + +export function flattenEntries(tree: TreeEntry[]): FileEntry[] { + const result: FileEntry[] = []; + for (const entry of tree) { + if (isCompound(entry)) { + result.push(...flattenEntries(entry.entries)); + } else { + result.push(entry); + } + } + return result; +} + +export interface Repository { + user: string; + description: string; + tree: TreeEntry[]; +} + +// ---- Pull Request types ---- + +export type PRStatus = 'open' | 'closed' | 'merged'; + +export interface FileDiff { + path: string; + oldContent: string; + newContent: string; + type: 'added' | 'modified' | 'deleted'; +} + +export interface PRCommit { + id: string; + message: string; + author: string; + createdAt: string; + diffs: FileDiff[]; +} + +export interface PRComment { + id: number; + author: string; + body: string; + createdAt: string; +} + +export type ActivityItem = + | { type: 'commit'; commit: PRCommit; createdAt: string } + | { type: 'comment'; comment: PRComment; createdAt: string } + | { type: 'status_change'; from: PRStatus; to: PRStatus; author: string; createdAt: string } + | { type: 'merge'; author: string; createdAt: string }; + +export interface PullRequest { + id: number; + title: string; + description: string; + status: PRStatus; + author: string; + createdAt: string; + updatedAt: string; + sourceVersion: string; + targetVersion: string; + sourceLabel: string; + targetLabel: string; + commits: PRCommit[]; + comments: PRComment[]; + activity: ActivityItem[]; + mergeable: boolean; +} + +const README_CONTENT = `# @ether/library + +A **Ray-based** library for compositional abstractions over equivalences. + +## Overview + +This library provides the foundational primitives for working with +*vertices*, *edges*, and *rays* in the Ether runtime. + +> "Every sufficiently advanced abstraction is indistinguishable from a ray." + +--- + +## Installation + +\`\`\`sh +ether add @ether/library +\`\`\` + +## Quick Start + +\`\`\`ts +import { Ray, Vertex, Edge } from '@ether/library'; + +const vertex = Ray.vertex(); +const edge = vertex.compose(Ray.vertex()); + +console.log(edge.is_equivalent(edge)); // true +\`\`\` + +## API Reference + +| Export | Type | Description | +|--------|------|-------------| +| \`Ray\` | class | The fundamental compositional primitive | +| \`Vertex\` | type | A zero-dimensional ray | +| \`Edge\` | type | A one-dimensional composition of rays | +| \`Orbit\` | type | A cyclic equivalence class | +| \`Mine\` | function | Constructs a ray from a perspective | + +### Ray Methods + +1. \`Ray.vertex()\` — create a vertex +2. \`Ray.edge(a, b)\` — compose two rays +3. \`Ray.orbit(rays)\` — create a cyclic structure +4. \`Ray.equivalent(a, b)\` — test equivalence + +### Features + +- [x] Zero-cost vertex abstraction +- [x] Compositional edge construction +- [x] Equivalence testing +- [ ] Parallel orbit resolution +- [ ] Distributed ray tracing + +## Examples + +See the [\`examples/\`](examples) directory for usage patterns: + +- **basic.ray** — A minimal vertex composition +- **composition.ray** — Chaining edges +- **orbit.ray** — Cyclic structures + +## License + +~~MIT~~ — *Unlicensed*. This is free and unencumbered software released into the public domain. + +![Ether Logo](images/avatar/2d.svg) +`; + +const ALT_README = `# @ether/library (Draft) + +An **experimental** rewrite of the core library using Ray v2 primitives. + +## Status + +This is an alternative README reflecting the in-progress v2 branch. + +> "All rays are equivalent — some are just more equivalent than others." + +--- + +## Changes from v1 + +- \`Ray.vertex()\` is now \`Ray.point()\` +- \`Ray.edge(a, b)\` replaced by \`Ray.connect(a, b)\` +- New: \`Ray.superpose(rays)\` — quantum-style superposition + +## Migration Guide + +\`\`\`ts +// v1 +const v = Ray.vertex(); +const e = v.compose(Ray.vertex()); + +// v2 +const p = Ray.point(); +const c = p.connect(Ray.point()); +\`\`\` + +## License + +~~MIT~~ — *Unlicensed*. This is free and unencumbered software released into the public domain. +`; + +const DOCS_README = `# Documentation + +Detailed guides for working with @ether/library. + +## Contents + +- \`getting-started.md\` — Setup and first steps +- \`api.md\` — Full API reference +- \`architecture.md\` — Internal design overview +`; + +const RAY_TS_V1 = `// Ray.ts — The fundamental compositional primitive +// v1: Original implementation + +import { Vertex } from './Vertex'; +import { Edge } from './Edge'; + +export type Equivalence = (a: T, b: T) => boolean; + +export class Ray { + private _initial: Ray | null = null; + private _terminal: Ray | null = null; + private _vertex: boolean; + + private constructor(vertex: boolean = false) { + this._vertex = vertex; + } + + static vertex(): Ray { + const r = new Ray(true); + r._initial = r; + r._terminal = r; + return r; + } + + static edge(a: Ray, b: Ray): Ray { + const r = new Ray(false); + r._initial = a; + r._terminal = b; + return r; + } + + static orbit(rays: Ray[]): Ray { + if (rays.length === 0) return Ray.vertex(); + let current = rays[0]; + for (let i = 1; i < rays.length; i++) { + current = Ray.edge(current, rays[i]); + } + return Ray.edge(current, rays[0]); + } + + get initial(): Ray { return this._initial ?? this; } + get terminal(): Ray { return this._terminal ?? this; } + get is_vertex(): boolean { return this._vertex; } + + compose(other: Ray): Ray { + return Ray.edge(this, other); + } + + is_equivalent(other: Ray): boolean { + if (this === other) return true; + if (this._vertex && other._vertex) return true; + return false; + } + + static equivalent(a: Ray, b: Ray): boolean { + return a.is_equivalent(b); + } + + toString(): string { + if (this._vertex) return '(*)'; + return \`(\${this._initial} -> \${this._terminal})\`; + } +}`; + +const RAY_TS_V2 = `// Ray.ts — The fundamental compositional primitive +// v2: Refactored with superposition support + +import { Vertex } from './Vertex'; +import { Edge } from './Edge'; + +export type Equivalence = (a: T, b: T) => boolean; + +export interface RayLike { + readonly initial: RayLike; + readonly terminal: RayLike; + readonly is_vertex: boolean; + compose(other: RayLike): RayLike; +} + +export class Ray implements RayLike { + private _initial: Ray | null = null; + private _terminal: Ray | null = null; + private _vertex: boolean; + private _superposed: Ray[] = []; + + private constructor(vertex: boolean = false) { + this._vertex = vertex; + } + + static point(): Ray { + const r = new Ray(true); + r._initial = r; + r._terminal = r; + return r; + } + + static vertex(): Ray { + return Ray.point(); + } + + static connect(a: Ray, b: Ray): Ray { + const r = new Ray(false); + r._initial = a; + r._terminal = b; + return r; + } + + static edge(a: Ray, b: Ray): Ray { + return Ray.connect(a, b); + } + + static superpose(...rays: Ray[]): Ray { + const r = Ray.point(); + r._superposed = rays; + return r; + } + + get initial(): Ray { return this._initial ?? this; } + get terminal(): Ray { return this._terminal ?? this; } + get is_vertex(): boolean { return this._vertex; } + get superpositions(): readonly Ray[] { return this._superposed; } + + compose(other: Ray): Ray { + return Ray.connect(this, other); + } + + is_equivalent(other: Ray): boolean { + if (this === other) return true; + if (this._vertex && other._vertex) return true; + if (this._superposed.length > 0 || other._superposed.length > 0) { + return this._superposed.some(s => s.is_equivalent(other)) + || other._superposed.some(s => this.is_equivalent(s)); + } + return false; + } + + toString(): string { + if (this._superposed.length > 0) { + return \`(\${this._superposed.map(s => s.toString()).join(' | ')})\`; + } + if (this._vertex) return '(*)'; + return \`(\${this._initial} -> \${this._terminal})\`; + } +}`; + +const VERTEX_TS_CONTENT = `// Vertex.ts — Zero-dimensional ray abstraction + +import { Ray } from './Ray'; + +export type Vertex = Ray; + +export function isVertex(ray: Ray): ray is Vertex { + return ray.is_vertex; +} + +export function createVertex(): Vertex { + return Ray.vertex(); +} + +export function vertexPair(): [Vertex, Vertex] { + return [createVertex(), createVertex()]; +} + +export namespace VertexOps { + export function merge(a: Vertex, b: Vertex): Vertex { + if (a.is_equivalent(b)) return a; + return Ray.edge(a, b); + } + + export function split(v: Vertex): [Ray, Ray] { + return [v.initial, v.terminal]; + } +}`; + +const EDGE_TS_CONTENT = `// Edge.ts — One-dimensional composition of rays + +import { Ray } from './Ray'; + +export type Edge = Ray; + +export function isEdge(ray: Ray): boolean { + return !ray.is_vertex; +} + +export function createEdge(initial: Ray, terminal: Ray): Edge { + return Ray.edge(initial, terminal); +} + +export function chain(...rays: Ray[]): Edge { + if (rays.length === 0) return Ray.vertex(); + let current = rays[0]; + for (let i = 1; i < rays.length; i++) { + current = current.compose(rays[i]); + } + return current; +} + +export function reverse(edge: Edge): Edge { + return Ray.edge(edge.terminal, edge.initial); +} + +export namespace EdgeOps { + export function length(edge: Edge): number { + let count = 0; + let current: Ray = edge; + while (!current.is_vertex) { + count++; + current = current.terminal; + } + return count; + } +}`; + +const ORBIT_TS_CONTENT = `// Orbit.ts — Cyclic equivalence class + +import { Ray } from './Ray'; +import { Edge, chain } from './Edge'; + +export class Orbit { + private _rays: Ray[]; + private _cycle: Edge; + + constructor(rays: Ray[]) { + if (rays.length === 0) { + throw new Error('Orbit requires at least one ray'); + } + this._rays = [...rays]; + this._cycle = Ray.orbit(rays); + } + + get rays(): readonly Ray[] { + return this._rays; + } + + get cycle(): Edge { + return this._cycle; + } + + get size(): number { + return this._rays.length; + } + + contains(ray: Ray): boolean { + return this._rays.some(r => r.is_equivalent(ray)); + } + + rotate(n: number = 1): Orbit { + const len = this._rays.length; + const shift = ((n % len) + len) % len; + const rotated = [ + ...this._rays.slice(shift), + ...this._rays.slice(0, shift), + ]; + return new Orbit(rotated); + } + + merge(other: Orbit): Orbit { + return new Orbit([...this._rays, ...other._rays]); + } + + toString(): string { + return \`Orbit(\${this._rays.map(r => r.toString()).join(', ')})\`; + } +}`; + +const INDEX_TS_CONTENT = `// index.ts — Main entry point for @ether/library + +export { Ray } from './Ray'; +export type { Equivalence, RayLike } from './Ray'; + +export { isVertex, createVertex, vertexPair } from './Vertex'; +export type { Vertex } from './Vertex'; + +export { isEdge, createEdge, chain, reverse } from './Edge'; +export type { Edge } from './Edge'; + +export { Orbit } from './Orbit'; + +// Re-export convenience constructors +import { Ray } from './Ray'; + +export const vertex = Ray.vertex; +export const edge = Ray.edge; + +export function mine(perspective: Ray): Ray { + return perspective.compose(Ray.vertex()); +}`; + +function generateLargeFile(lines: number): string { + const parts: string[] = [ + '// generated-test.ray — Auto-generated test file', + '// This file is used to test virtual scrolling with large content', + '', + 'import { Ray, Vertex, Edge, Orbit } from "../src";', + '', + '// ============================================================', + '// Test harness: generate a mesh of rays for stress testing', + '// ============================================================', + '', + 'const MESH_SIZE = 1000;', + 'const vertices: Ray[] = [];', + '', + 'for (let i = 0; i < MESH_SIZE; i++) {', + ' vertices.push(Ray.vertex());', + '}', + '', + ]; + for (let i = parts.length; i < lines; i++) { + const mod = i % 20; + if (mod === 0) { + parts.push(`// ---- Block ${Math.floor(i / 20)} ----`); + } else if (mod === 1) { + parts.push(`const ray_${i} = Ray.vertex();`); + } else if (mod === 2) { + parts.push(`const edge_${i} = Ray.edge(ray_${i - 1}, Ray.vertex());`); + } else if (mod === 3) { + parts.push(`assert(edge_${i - 1}.is_equivalent(edge_${i - 1}));`); + } else if (mod === 4) { + parts.push(`assert(!ray_${i - 3}.is_equivalent(edge_${i - 2}));`); + } else if (mod === 5) { + parts.push(`const orbit_${i} = new Orbit([ray_${i - 4}, edge_${i - 3}]);`); + } else if (mod === 6) { + parts.push(`assert(orbit_${i - 1}.size === 2);`); + } else if (mod === 7) { + parts.push(`assert(orbit_${i - 2}.contains(ray_${i - 6}));`); + } else if (mod === 8) { + parts.push(`const composed_${i} = ray_${i - 7}.compose(edge_${i - 6});`); + } else if (mod === 9) { + parts.push(`assert(!composed_${i - 1}.is_vertex);`); + } else if (mod === 10) { + parts.push(''); + } else if (mod === 11) { + parts.push(`// Verify vertex identity for block ${Math.floor(i / 20)}`); + } else if (mod === 12) { + parts.push(`const v_${i} = Ray.vertex();`); + } else if (mod === 13) { + parts.push(`assert(v_${i - 1}.is_vertex);`); + } else if (mod === 14) { + parts.push(`assert(v_${i - 2}.initial === v_${i - 2});`); + } else if (mod === 15) { + parts.push(`assert(v_${i - 3}.terminal === v_${i - 3});`); + } else if (mod === 16) { + parts.push(`const chain_${i} = v_${i - 4}.compose(ray_${i - 15});`); + } else if (mod === 17) { + parts.push(`assert(!chain_${i - 1}.is_vertex);`); + } else if (mod === 18) { + parts.push(`vertices.push(v_${i - 6});`); + } else { + parts.push(''); + } + } + return parts.slice(0, lines).join('\n'); +} + +let _largeFileCache: string | null = null; +const generatedTestEntry: FileEntry = { name: 'generated-test.ray', isDirectory: false, modified: '1 day ago' }; +Object.defineProperty(generatedTestEntry, 'content', { + get() { + if (_largeFileCache === null) _largeFileCache = generateLargeFile(10000); + return _largeFileCache; + }, + enumerable: true, + configurable: true, +}); + +const repository: Repository = { + user: 'ether', + description: 'The Ether runtime environment', + tree: [ + { + name: 'library', + isDirectory: true, + modified: 'yesterday', + children: [ + { + name: 'src', + isDirectory: true, + modified: '2 days ago', + children: [ + { op: '|', entries: [ + { name: 'Ray.ts', isDirectory: false, modified: '2 days ago', content: RAY_TS_V1 }, + { name: 'Ray.ts', isDirectory: false, modified: '5 days ago', content: RAY_TS_V2 }, + ]}, + { name: 'Vertex.ts', isDirectory: false, modified: '5 days ago', content: VERTEX_TS_CONTENT }, + { name: 'Edge.ts', isDirectory: false, modified: '5 days ago', content: EDGE_TS_CONTENT }, + { name: 'Orbit.ts', isDirectory: false, modified: '3 days ago', content: ORBIT_TS_CONTENT, access: 'player' }, + { name: 'index.ts', isDirectory: false, modified: '2 days ago', content: INDEX_TS_CONTENT, children: [ + { name: 'types', isDirectory: true, modified: '3 days ago', children: [ + { name: 'Ray.d.ts', isDirectory: false, modified: '3 days ago' }, + { name: 'index.d.ts', isDirectory: false, modified: '3 days ago' }, + ]}, + ]}, + ], + }, + { + name: 'docs', + isDirectory: true, + modified: '1 week ago', + children: [ + { name: 'getting-started.md', isDirectory: false, modified: '1 week ago' }, + { name: 'api.md', isDirectory: false, modified: '1 week ago' }, + { name: 'architecture.md', isDirectory: false, modified: '2 weeks ago', access: 'npc' }, + { name: 'README.md', isDirectory: false, modified: '1 week ago', content: DOCS_README }, + ], + }, + { + name: 'examples', + isDirectory: true, + modified: '4 days ago', + children: [ + { name: 'basic.ray', isDirectory: false, modified: '1 week ago', access: 'everyone' }, + { name: 'composition.ray', isDirectory: false, modified: '4 days ago' }, + { name: 'orbit.ray', isDirectory: false, modified: '4 days ago' }, + generatedTestEntry, + ], + }, + { + name: 'assets', + isDirectory: true, + modified: '2 weeks ago', + children: [ + { name: 'logo.svg', isDirectory: false, modified: '2 weeks ago' }, + { name: 'banner.png', isDirectory: false, modified: '2 weeks ago' }, + ], + }, + { + op: '|', + entries: [ + { name: 'README.md', isDirectory: false, modified: 'yesterday', content: README_CONTENT }, + { name: 'README.md', isDirectory: false, modified: '3 days ago', content: ALT_README }, + ], + }, + { name: 'index.ray.js', isDirectory: false, modified: 'today', content: `document.body.style.background = '#0a0a0a'; +document.body.style.color = '#fff'; +document.body.style.fontFamily = "'Courier New', monospace"; + +window.addEventListener('ether:ready', async () => { + const el = document.createElement('div'); + el.style.padding = '40px'; + + const count = parseInt(await ether.storage.get('visits') || '0') + 1; + await ether.storage.set('visits', String(count)); + + el.innerHTML = \`

@ether/library

+

Hello, @\${ether.user}.

+

Visit #\${count}

\`; + document.body.appendChild(el); +}); +` }, + { + op: '&', + entries: [ + { name: 'package.json', isDirectory: false, modified: '3 days ago' }, + { name: 'tsconfig.json', isDirectory: false, modified: '1 week ago' }, + ], + }, + { name: 'LICENSE', isDirectory: false, modified: '1 month ago' }, + { name: '.gitignore', isDirectory: false, modified: '1 month ago', access: 'private' }, + { + name: '@annotations', + isDirectory: true, + modified: '1 day ago', + access: 'local', + children: [ + { name: 'design-notes.ray', isDirectory: false, modified: '1 day ago', content: '// @annotations: Design notes\n// This directory tests @-prefix escaping' }, + ], + }, + { + name: '~drafts', + isDirectory: true, + modified: '2 days ago', + access: 'private', + encrypted: true, + children: [ + { name: 'wip.ray', isDirectory: false, modified: '2 days ago', content: '// ~drafts: Work in progress\n// This directory tests ~-prefix escaping' }, + ], + }, + { + name: '*', + isDirectory: true, + modified: '3 days ago', + children: [ + { name: 'glob-match.ray', isDirectory: false, modified: '3 days ago', content: '// * directory: glob patterns\n// This tests *-exact escaping' }, + ], + }, + { + name: '-', + isDirectory: true, + modified: '4 days ago', + children: [ + { name: 'archive.ray', isDirectory: false, modified: '4 days ago', content: '// - directory: archive\n// This tests dash-exact escaping' }, + ], + }, + { + name: '.ether', + isDirectory: true, + modified: 'today', + access: 'private', + children: [ + { + name: '%', + isDirectory: true, + modified: 'today', + children: [ + { + name: 'pull-requests', + isDirectory: true, + modified: 'today', + children: [ + { name: '0.ray', isDirectory: false, modified: '3 days ago', content: '# PR #0: Add Orbit cyclic structure support\nstatus: merged\nauthor: @bob\nsource: @bob/orbit-support\ntarget: main' }, + { name: '1.ray', isDirectory: false, modified: '1 week ago', content: '# PR #1: Refactor Edge to use generics\nstatus: closed\nauthor: @charlie\nsource: @charlie/edge-generics\ntarget: main' }, + { name: '2.ray', isDirectory: false, modified: 'today', content: '# PR #2: Add superposition support to Ray\nstatus: open\nauthor: @alice\nsource: @alice/superposition\ntarget: main' }, + { name: '3.ray', isDirectory: false, modified: 'today', content: '# PR #3: Improve documentation README\nstatus: open\nauthor: @alice\nsource: @alice/docs-update\ntarget: main' }, + ], + }, + ], + }, + ], + }, + ], + }, + ], +}; + +const genesisWorld: Repository = { + user: 'genesis', + description: 'The origin world — where it all began', + tree: [ + { + name: 'terrain', + isDirectory: true, + modified: '3 days ago', + children: [ + { name: 'heightmap.ray', isDirectory: false, modified: '3 days ago' }, + { name: 'biomes.ray', isDirectory: false, modified: '1 week ago' }, + ], + }, + { + name: 'entities', + isDirectory: true, + modified: 'yesterday', + children: [ + { name: 'player.ray', isDirectory: false, modified: 'yesterday', access: 'player' }, + { name: 'npc.ray', isDirectory: false, modified: '4 days ago', access: 'npc' }, + ], + }, + { name: 'world.config', isDirectory: false, modified: '2 days ago', access: 'everyone' }, + { name: 'README.md', isDirectory: false, modified: '1 week ago' }, + ], +}; + +const sandboxWorld: Repository = { + user: 'sandbox', + description: 'An experimental sandbox world', + tree: [ + { + name: 'experiments', + isDirectory: true, + modified: 'yesterday', + children: [ + { name: 'gravity.ray', isDirectory: false, modified: 'yesterday' }, + { name: 'time-dilation.ray', isDirectory: false, modified: '3 days ago' }, + ], + }, + { name: 'world.config', isDirectory: false, modified: '1 week ago' }, + { name: 'README.md', isDirectory: false, modified: '2 weeks ago' }, + ], +}; + +const ALICE_PROFILE_SVG = ` + + + + + +`; + +const aliceRepo: Repository = { + user: 'alice', + description: 'Alice — a genesis inhabitant', + tree: [ + { name: 'avatar', isDirectory: true, modified: '1 week ago', children: [ + { name: '2d-square.svg', isDirectory: false, modified: '1 week ago', content: ALICE_PROFILE_SVG }, + ]}, + { name: 'notes.md', isDirectory: false, modified: '2 days ago', access: 'private' }, + ], +}; + +const bobRepo: Repository = { + user: 'bob', + description: 'Bob — a genesis builder', + tree: [ + { name: 'blueprints', isDirectory: true, modified: 'yesterday', children: [ + { name: 'tower.ray', isDirectory: false, modified: 'yesterday' }, + ]}, + ], +}; + +const charlieRepo: Repository = { + user: 'charlie', + description: 'Charlie — sandbox tester', + tree: [ + { name: 'logs', isDirectory: true, modified: '1 week ago', children: [ + { name: 'test-run-1.log', isDirectory: false, modified: '1 week ago' }, + ]}, + ], +}; + +const alphaWorld: Repository = { + user: 'alpha', + description: 'A nested sub-world within genesis', + tree: [ + { name: 'seed.ray', isDirectory: false, modified: '5 days ago' }, + ], +}; + +const allRepositories: Repository[] = [repository, aliceRepo, bobRepo, charlieRepo]; + +const allWorlds: Map> = new Map([ + ['ether', new Map([ + ['genesis', genesisWorld], + ['sandbox', sandboxWorld], + ])], + ['genesis', new Map([ + ['alpha', alphaWorld], + ])], +]); + +export function getRepository(user: string): Repository | null { + return allRepositories.find(r => r.user === user) || null; +} + +export function getReferencedUsers(user: string, world?: string | null): string[] { + if (world === 'genesis') return ['alice', 'bob']; + if (world === 'sandbox') return ['charlie']; + if (world) return []; // inside a world with no explicit entries + if (user === 'ether') return ['ether']; + return []; +} + +export function getReferencedWorlds(user: string, world?: string | null): string[] { + if (world === 'genesis') return ['alpha']; + if (world === 'sandbox') return []; + if (world) return []; // inside a world with no explicit entries + const worlds = allWorlds.get(user); + return worlds ? [...worlds.keys()] : []; +} + +export function getWorld(user: string, world: string): Repository | null { + return allWorlds.get(user)?.get(world) || null; +} + +export function resolveDirectory(tree: TreeEntry[], pathSegments: string[]): TreeEntry[] | null { + let current = tree; + for (const segment of pathSegments) { + const flat = flattenEntries(current); + const entry = flat.find(e => e.name === segment && e.isDirectory); + if (!entry || !entry.children) return null; + current = entry.children; + } + return current; +} + +export function resolveFile(tree: TreeEntry[], pathSegments: string[]): FileEntry | null { + if (pathSegments.length === 0) return null; + const dirPath = pathSegments.slice(0, -1); + const fileName = pathSegments[pathSegments.length - 1]; + const dir = dirPath.length > 0 ? resolveDirectory(tree, dirPath) : tree; + if (!dir) return null; + const flat = flattenEntries(dir); + return flat.find(e => e.name === fileName && !e.isDirectory) || null; +} + +/** Like resolveDirectory but also traverses file entries that have children. */ +function resolveFlexible(tree: TreeEntry[], pathSegments: string[]): TreeEntry[] | null { + let current = tree; + for (const segment of pathSegments) { + const flat = flattenEntries(current); + const entry = flat.find(e => e.name === segment && (e.isDirectory || (e.children && e.children.length > 0))); + if (!entry || !entry.children) return null; + current = entry.children; + } + return current; +} + +export function resolveFiles(tree: TreeEntry[], pathSegments: string[]): FileEntry[] { + if (pathSegments.length === 0) return []; + const dirPath = pathSegments.slice(0, -1); + const fileName = pathSegments[pathSegments.length - 1]; + const dir = dirPath.length > 0 ? resolveFlexible(tree, dirPath) : tree; + if (!dir) return []; + const flat = flattenEntries(dir); + return flat.filter(e => e.name === fileName && !e.isDirectory); +} + +// ---- Pull Request dummy data ---- + +const dummyPullRequests: Map = new Map(); + +// PRs for @ether/library +const etherLibraryPRs: PullRequest[] = [ + { + id: 0, + title: 'Add Orbit cyclic structure support', + description: `Adds the \`Orbit\` class for representing cyclic equivalence classes of rays.\n\nThis introduces:\n- \`Orbit\` constructor from a list of rays\n- \`rotate()\`, \`merge()\`, \`contains()\` methods\n- Cycle edge construction via \`Ray.orbit()\``, + status: 'merged', + author: 'bob', + createdAt: '2025-12-01T10:00:00Z', + updatedAt: '2025-12-03T14:00:00Z', + sourceVersion: 'a1b2c3d4-e5f6-11ee-b001-000000000001', + targetVersion: 'a1b2c3d4-e5f6-11ee-b001-000000000000', + sourceLabel: 'bob/orbit-support', + targetLabel: 'main', + commits: [ + { + id: 'c0a1b2c3-d4e5-11ee-b001-000000000010', + message: 'Add Orbit class with cyclic structure operations', + author: 'bob', + createdAt: '2025-12-01T10:30:00Z', + diffs: [ + { + path: 'src/Orbit.ts', + oldContent: '', + newContent: ORBIT_TS_CONTENT, + type: 'added', + }, + ], + }, + ], + comments: [ + { id: 0, author: 'alice', body: 'Looks great! The `rotate()` method is exactly what we needed for the cycle resolution algorithm.', createdAt: '2025-12-01T15:00:00Z' }, + { id: 1, author: 'bob', body: 'Thanks! I also added `merge()` for combining orbits — should help with the distributed case.', createdAt: '2025-12-02T09:00:00Z' }, + ], + activity: [ + { type: 'commit', commit: { id: 'c0a1b2c3-d4e5-11ee-b001-000000000010', message: 'Add Orbit class with cyclic structure operations', author: 'bob', createdAt: '2025-12-01T10:30:00Z', diffs: [] }, createdAt: '2025-12-01T10:30:00Z' }, + { type: 'comment', comment: { id: 0, author: 'alice', body: 'Looks great! The `rotate()` method is exactly what we needed for the cycle resolution algorithm.', createdAt: '2025-12-01T15:00:00Z' }, createdAt: '2025-12-01T15:00:00Z' }, + { type: 'comment', comment: { id: 1, author: 'bob', body: 'Thanks! I also added `merge()` for combining orbits — should help with the distributed case.', createdAt: '2025-12-02T09:00:00Z' }, createdAt: '2025-12-02T09:00:00Z' }, + { type: 'status_change', from: 'open', to: 'merged', author: 'alice', createdAt: '2025-12-03T14:00:00Z' }, + { type: 'merge', author: 'alice', createdAt: '2025-12-03T14:00:00Z' }, + ], + mergeable: false, + }, + { + id: 1, + title: 'Refactor Edge to use generics', + description: `Refactors the \`Edge\` module to use generic type parameters for better type inference.\n\nThis is a breaking change for downstream consumers that rely on the concrete \`Ray\` type in edge construction.`, + status: 'closed', + author: 'charlie', + createdAt: '2025-12-05T08:00:00Z', + updatedAt: '2025-12-08T12:00:00Z', + sourceVersion: 'b2c3d4e5-f6a1-11ee-b002-000000000001', + targetVersion: 'b2c3d4e5-f6a1-11ee-b002-000000000000', + sourceLabel: 'charlie/edge-generics', + targetLabel: 'main', + commits: [ + { + id: 'c1b2c3d4-e5f6-11ee-b002-000000000010', + message: 'Refactor Edge module with generic type parameters', + author: 'charlie', + createdAt: '2025-12-05T09:00:00Z', + diffs: [ + { + path: 'src/Edge.ts', + oldContent: EDGE_TS_CONTENT, + newContent: `// Edge.ts — One-dimensional composition of rays (generic) + +import { Ray } from './Ray'; + +export type Edge = T; + +export function isEdge(ray: Ray): boolean { + return !ray.is_vertex; +} + +export function createEdge(initial: T, terminal: T): Edge { + return Ray.edge(initial, terminal) as Edge; +} + +export function chain(...rays: T[]): Edge { + if (rays.length === 0) return Ray.vertex() as Edge; + let current: Ray = rays[0]; + for (let i = 1; i < rays.length; i++) { + current = current.compose(rays[i]); + } + return current as Edge; +} + +export function reverse(edge: Edge): Edge { + return Ray.edge(edge.terminal, edge.initial) as Edge; +} + +export namespace EdgeOps { + export function length(edge: Edge): number { + let count = 0; + let current: Ray = edge; + while (!current.is_vertex) { + count++; + current = current.terminal; + } + return count; + } +}`, + type: 'modified', + }, + ], + }, + ], + comments: [ + { id: 2, author: 'alice', body: 'I think this introduces too much complexity for the current use case. Can we revisit after the v2 migration?', createdAt: '2025-12-06T10:00:00Z' }, + ], + activity: [ + { type: 'commit', commit: { id: 'c1b2c3d4-e5f6-11ee-b002-000000000010', message: 'Refactor Edge module with generic type parameters', author: 'charlie', createdAt: '2025-12-05T09:00:00Z', diffs: [] }, createdAt: '2025-12-05T09:00:00Z' }, + { type: 'comment', comment: { id: 2, author: 'alice', body: 'I think this introduces too much complexity for the current use case. Can we revisit after the v2 migration?', createdAt: '2025-12-06T10:00:00Z' }, createdAt: '2025-12-06T10:00:00Z' }, + { type: 'status_change', from: 'open', to: 'closed', author: 'charlie', createdAt: '2025-12-08T12:00:00Z' }, + ], + mergeable: false, + }, + { + id: 2, + title: 'Add superposition support to Ray', + description: `Introduces superposition semantics to the \`Ray\` class, enabling quantum-style composition.\n\n## Changes\n- New \`Ray.superpose(...rays)\` static method\n- New \`superpositions\` getter\n- Updated \`is_equivalent\` to handle superposed rays\n- Added \`RayLike\` interface for structural typing`, + status: 'open', + author: 'alice', + createdAt: '2025-12-10T09:00:00Z', + updatedAt: '2025-12-12T16:00:00Z', + sourceVersion: 'c3d4e5f6-a1b2-11ee-b003-000000000001', + targetVersion: 'c3d4e5f6-a1b2-11ee-b003-000000000000', + sourceLabel: 'alice/superposition', + targetLabel: 'main', + commits: [ + { + id: 'c2a1b2c3-d4e5-11ee-b003-000000000010', + message: 'Add RayLike interface and superpose static method', + author: 'alice', + createdAt: '2025-12-10T10:00:00Z', + diffs: [ + { + path: 'src/Ray.ts', + oldContent: RAY_TS_V1, + newContent: `// Ray.ts — The fundamental compositional primitive +// v1.5: Adding superposition groundwork + +import { Vertex } from './Vertex'; +import { Edge } from './Edge'; + +export type Equivalence = (a: T, b: T) => boolean; + +export interface RayLike { + readonly initial: RayLike; + readonly terminal: RayLike; + readonly is_vertex: boolean; + compose(other: RayLike): RayLike; +} + +export class Ray implements RayLike { + private _initial: Ray | null = null; + private _terminal: Ray | null = null; + private _vertex: boolean; + + private constructor(vertex: boolean = false) { + this._vertex = vertex; + } + + static vertex(): Ray { + const r = new Ray(true); + r._initial = r; + r._terminal = r; + return r; + } + + static edge(a: Ray, b: Ray): Ray { + const r = new Ray(false); + r._initial = a; + r._terminal = b; + return r; + } + + static orbit(rays: Ray[]): Ray { + if (rays.length === 0) return Ray.vertex(); + let current = rays[0]; + for (let i = 1; i < rays.length; i++) { + current = Ray.edge(current, rays[i]); + } + return Ray.edge(current, rays[0]); + } + + get initial(): Ray { return this._initial ?? this; } + get terminal(): Ray { return this._terminal ?? this; } + get is_vertex(): boolean { return this._vertex; } + + compose(other: Ray): Ray { + return Ray.edge(this, other); + } + + is_equivalent(other: Ray): boolean { + if (this === other) return true; + if (this._vertex && other._vertex) return true; + return false; + } + + static equivalent(a: Ray, b: Ray): boolean { + return a.is_equivalent(b); + } + + toString(): string { + if (this._vertex) return '(*)'; + return \`(\${this._initial} -> \${this._terminal})\`; + } +}`, + type: 'modified', + }, + ], + }, + { + id: 'c2b1c2d3-e4f5-11ee-b003-000000000011', + message: 'Implement full superposition with equivalence checks', + author: 'alice', + createdAt: '2025-12-11T14:00:00Z', + diffs: [ + { + path: 'src/Ray.ts', + oldContent: RAY_TS_V1, + newContent: RAY_TS_V2, + type: 'modified', + }, + ], + }, + ], + comments: [ + { id: 3, author: 'bob', body: 'The `RayLike` interface is a nice touch. Will this support cross-universe superposition eventually?', createdAt: '2025-12-10T14:00:00Z' }, + { id: 4, author: 'alice', body: 'That is the plan! This PR lays the groundwork. Cross-universe will come in a follow-up.', createdAt: '2025-12-10T16:00:00Z' }, + { id: 5, author: 'charlie', body: 'I tested the equivalence changes — they pass all existing test cases plus the new superposition ones.', createdAt: '2025-12-12T11:00:00Z' }, + ], + activity: [ + { type: 'commit', commit: { id: 'c2a1b2c3-d4e5-11ee-b003-000000000010', message: 'Add RayLike interface and superpose static method', author: 'alice', createdAt: '2025-12-10T10:00:00Z', diffs: [] }, createdAt: '2025-12-10T10:00:00Z' }, + { type: 'comment', comment: { id: 3, author: 'bob', body: 'The `RayLike` interface is a nice touch. Will this support cross-universe superposition eventually?', createdAt: '2025-12-10T14:00:00Z' }, createdAt: '2025-12-10T14:00:00Z' }, + { type: 'comment', comment: { id: 4, author: 'alice', body: 'That is the plan! This PR lays the groundwork. Cross-universe will come in a follow-up.', createdAt: '2025-12-10T16:00:00Z' }, createdAt: '2025-12-10T16:00:00Z' }, + { type: 'commit', commit: { id: 'c2b1c2d3-e4f5-11ee-b003-000000000011', message: 'Implement full superposition with equivalence checks', author: 'alice', createdAt: '2025-12-11T14:00:00Z', diffs: [] }, createdAt: '2025-12-11T14:00:00Z' }, + { type: 'comment', comment: { id: 5, author: 'charlie', body: 'I tested the equivalence changes — they pass all existing test cases plus the new superposition ones.', createdAt: '2025-12-12T11:00:00Z' }, createdAt: '2025-12-12T11:00:00Z' }, + ], + mergeable: true, + }, + { + id: 3, + title: 'Improve documentation README', + description: `Updates the README to reflect the v2 API changes and adds migration guide.\n\nThis aligns the documentation with the in-progress superposition branch.`, + status: 'open', + author: 'alice', + createdAt: '2025-12-13T11:00:00Z', + updatedAt: '2025-12-13T11:00:00Z', + sourceVersion: 'd4e5f6a1-b2c3-11ee-b004-000000000001', + targetVersion: 'd4e5f6a1-b2c3-11ee-b004-000000000000', + sourceLabel: 'alice/docs-update', + targetLabel: 'main', + commits: [ + { + id: 'c3a1b2c3-d4e5-11ee-b004-000000000010', + message: 'Update README with v2 API changes and migration guide', + author: 'alice', + createdAt: '2025-12-13T11:30:00Z', + diffs: [ + { + path: 'README.md', + oldContent: README_CONTENT, + newContent: ALT_README, + type: 'modified', + }, + ], + }, + ], + comments: [], + activity: [ + { type: 'commit', commit: { id: 'c3a1b2c3-d4e5-11ee-b004-000000000010', message: 'Update README with v2 API changes and migration guide', author: 'alice', createdAt: '2025-12-13T11:30:00Z', diffs: [] }, createdAt: '2025-12-13T11:30:00Z' }, + ], + mergeable: true, + }, +]; + +dummyPullRequests.set('@ether/library', etherLibraryPRs); + +// PRs for @ether/library/assets (nested sub-path) +const etherLibraryAssetsPRs: PullRequest[] = [ + { + id: 100, + title: 'Add high-res banner variants', + description: 'Adds 2x and 3x resolution variants of the banner for retina displays.', + status: 'open', + author: 'bob', + createdAt: '2025-12-14T09:00:00Z', + updatedAt: '2025-12-14T09:00:00Z', + sourceVersion: 'e5f6a1b2-c3d4-11ee-b005-000000000001', + targetVersion: 'e5f6a1b2-c3d4-11ee-b005-000000000000', + sourceLabel: 'bob/hires-assets', + targetLabel: 'main', + commits: [ + { + id: 'c4a1b2c3-d4e5-11ee-b005-000000000010', + message: 'Add banner@2x.png and banner@3x.png', + author: 'bob', + createdAt: '2025-12-14T09:30:00Z', + diffs: [ + { path: 'banner@2x.png', oldContent: '', newContent: '(binary)', type: 'added' }, + { path: 'banner@3x.png', oldContent: '', newContent: '(binary)', type: 'added' }, + ], + }, + ], + comments: [ + { id: 10, author: 'alice', body: 'These look sharp! Can we also get an SVG version?', createdAt: '2025-12-14T12:00:00Z' }, + ], + activity: [ + { type: 'commit', commit: { id: 'c4a1b2c3-d4e5-11ee-b005-000000000010', message: 'Add banner@2x.png and banner@3x.png', author: 'bob', createdAt: '2025-12-14T09:30:00Z', diffs: [] }, createdAt: '2025-12-14T09:30:00Z' }, + { type: 'comment', comment: { id: 10, author: 'alice', body: 'These look sharp! Can we also get an SVG version?', createdAt: '2025-12-14T12:00:00Z' }, createdAt: '2025-12-14T12:00:00Z' }, + ], + mergeable: true, + }, + { + id: 101, + title: 'Update logo color scheme', + description: 'Changes the logo to match the new brand guidelines.', + status: 'merged', + author: 'alice', + createdAt: '2025-12-08T10:00:00Z', + updatedAt: '2025-12-09T15:00:00Z', + sourceVersion: 'f6a1b2c3-d4e5-11ee-b006-000000000001', + targetVersion: 'f6a1b2c3-d4e5-11ee-b006-000000000000', + sourceLabel: 'alice/logo-update', + targetLabel: 'main', + commits: [ + { + id: 'c5a1b2c3-d4e5-11ee-b006-000000000010', + message: 'Update logo.svg with new color palette', + author: 'alice', + createdAt: '2025-12-08T10:30:00Z', + diffs: [ + { path: 'logo.svg', oldContent: '', newContent: '', type: 'modified' }, + ], + }, + ], + comments: [], + activity: [ + { type: 'commit', commit: { id: 'c5a1b2c3-d4e5-11ee-b006-000000000010', message: 'Update logo.svg with new color palette', author: 'alice', createdAt: '2025-12-08T10:30:00Z', diffs: [] }, createdAt: '2025-12-08T10:30:00Z' }, + { type: 'merge', author: 'bob', createdAt: '2025-12-09T15:00:00Z' }, + ], + mergeable: false, + }, +]; + +dummyPullRequests.set('@ether/library/assets', etherLibraryAssetsPRs); + +// PRs for @ether/genesis (world — separate from inline listing) +const etherGenesisPRs: PullRequest[] = [ + { + id: 200, + title: 'Update terrain heightmap generator', + description: 'Improves terrain generation with Perlin noise for smoother landscapes.', + status: 'open', + author: 'alice', + createdAt: '2025-12-15T09:00:00Z', + updatedAt: '2025-12-15T09:00:00Z', + sourceVersion: 'g1a1b2c3-d4e5-11ee-b010-000000000001', + targetVersion: 'g1a1b2c3-d4e5-11ee-b010-000000000000', + sourceLabel: 'alice/terrain-update', + targetLabel: 'main', + commits: [{ + id: 'cg1b2c3d-e4f5-11ee-b010-000000000010', + message: 'Implement Perlin noise heightmap generation', + author: 'alice', + createdAt: '2025-12-15T09:30:00Z', + diffs: [{ path: 'terrain/heightmap.ray', oldContent: '// old heightmap', newContent: '// new Perlin noise heightmap', type: 'modified' }], + }], + comments: [{ id: 20, author: 'bob', body: 'The noise parameters look good. Maybe add a seed option?', createdAt: '2025-12-15T14:00:00Z' }], + activity: [ + { type: 'commit', commit: { id: 'cg1b2c3d-e4f5-11ee-b010-000000000010', message: 'Implement Perlin noise heightmap generation', author: 'alice', createdAt: '2025-12-15T09:30:00Z', diffs: [] }, createdAt: '2025-12-15T09:30:00Z' }, + { type: 'comment', comment: { id: 20, author: 'bob', body: 'The noise parameters look good. Maybe add a seed option?', createdAt: '2025-12-15T14:00:00Z' }, createdAt: '2025-12-15T14:00:00Z' }, + ], + mergeable: true, + }, + { + id: 201, + title: 'Add NPC dialogue system', + description: 'Implements a basic dialogue tree system for world NPCs.', + status: 'open', + author: 'bob', + createdAt: '2025-12-16T11:00:00Z', + updatedAt: '2025-12-16T11:00:00Z', + sourceVersion: 'g2a1b2c3-d4e5-11ee-b011-000000000001', + targetVersion: 'g2a1b2c3-d4e5-11ee-b011-000000000000', + sourceLabel: 'bob/npc-dialogue', + targetLabel: 'main', + commits: [{ + id: 'cg2b2c3d-e4f5-11ee-b011-000000000010', + message: 'Add basic dialogue tree for NPCs', + author: 'bob', + createdAt: '2025-12-16T11:30:00Z', + diffs: [{ path: 'entities/npc.ray', oldContent: '// basic npc', newContent: '// npc with dialogue', type: 'modified' }], + }], + comments: [], + activity: [ + { type: 'commit', commit: { id: 'cg2b2c3d-e4f5-11ee-b011-000000000010', message: 'Add basic dialogue tree for NPCs', author: 'bob', createdAt: '2025-12-16T11:30:00Z', diffs: [] }, createdAt: '2025-12-16T11:30:00Z' }, + ], + mergeable: true, + }, +]; + +// Store world PRs under ~genesis (world prefix), not the folder genesis +dummyPullRequests.set('@ether/~genesis', etherGenesisPRs); + +// PRs for @ether/@alice (player sub-namespace) +const etherAlicePRs: PullRequest[] = [ + { + id: 300, + title: 'Update profile configuration', + description: 'Reorganizes profile settings and adds new bio section.', + status: 'open', + author: 'alice', + createdAt: '2025-12-17T10:00:00Z', + updatedAt: '2025-12-17T10:00:00Z', + sourceVersion: 'p1a1b2c3-d4e5-11ee-b020-000000000001', + targetVersion: 'p1a1b2c3-d4e5-11ee-b020-000000000000', + sourceLabel: 'alice/profile-update', + targetLabel: 'main', + commits: [{ + id: 'cp1b2c3d-e4f5-11ee-b020-000000000010', + message: 'Add bio section to profile', + author: 'alice', + createdAt: '2025-12-17T10:30:00Z', + diffs: [{ path: 'profile.ray', oldContent: '// basic profile', newContent: '// profile with bio', type: 'modified' }], + }], + comments: [], + activity: [ + { type: 'commit', commit: { id: 'cp1b2c3d-e4f5-11ee-b020-000000000010', message: 'Add bio section to profile', author: 'alice', createdAt: '2025-12-17T10:30:00Z', diffs: [] }, createdAt: '2025-12-17T10:30:00Z' }, + ], + mergeable: true, + }, +]; + +dummyPullRequests.set('@ether/@alice', etherAlicePRs); + +// PRs for nested world ~alpha within ~genesis +const genesisAlphaPRs: PullRequest[] = [ + { + id: 400, + title: 'Initialize world seed parameters', + description: 'Sets up the initial seed configuration for the alpha sub-world.', + status: 'open', + author: 'alice', + createdAt: '2025-12-18T09:00:00Z', + updatedAt: '2025-12-18T09:00:00Z', + sourceVersion: 'h1a1b2c3-d4e5-11ee-b030-000000000001', + targetVersion: 'h1a1b2c3-d4e5-11ee-b030-000000000000', + sourceLabel: 'alice/alpha-seed', + targetLabel: 'main', + commits: [{ + id: 'ch1b2c3d-e4f5-11ee-b030-000000000010', + message: 'Configure alpha world seed parameters', + author: 'alice', + createdAt: '2025-12-18T09:30:00Z', + diffs: [{ path: 'seed.ray', oldContent: '// empty seed', newContent: '// configured seed with params', type: 'modified' }], + }], + comments: [], + activity: [ + { type: 'commit', commit: { id: 'ch1b2c3d-e4f5-11ee-b030-000000000010', message: 'Configure alpha world seed parameters', author: 'alice', createdAt: '2025-12-18T09:30:00Z', diffs: [] }, createdAt: '2025-12-18T09:30:00Z' }, + ], + mergeable: true, + }, +]; + +dummyPullRequests.set('@ether/~genesis/~alpha', genesisAlphaPRs); + +// PRs for player @bob within ~genesis +const genesisBobPRs: PullRequest[] = [ + { + id: 401, + title: 'Add builder toolkit blueprints', + description: 'Adds Bob\'s builder toolkit with tower blueprints for genesis.', + status: 'open', + author: 'bob', + createdAt: '2025-12-19T11:00:00Z', + updatedAt: '2025-12-19T11:00:00Z', + sourceVersion: 'h2a1b2c3-d4e5-11ee-b031-000000000001', + targetVersion: 'h2a1b2c3-d4e5-11ee-b031-000000000000', + sourceLabel: 'bob/builder-toolkit', + targetLabel: 'main', + commits: [{ + id: 'ch2b2c3d-e4f5-11ee-b031-000000000010', + message: 'Add tower blueprint templates', + author: 'bob', + createdAt: '2025-12-19T11:30:00Z', + diffs: [{ path: 'blueprints/tower.ray', oldContent: '', newContent: '// tower blueprint v2', type: 'added' }], + }], + comments: [], + activity: [ + { type: 'commit', commit: { id: 'ch2b2c3d-e4f5-11ee-b031-000000000010', message: 'Add tower blueprint templates', author: 'bob', createdAt: '2025-12-19T11:30:00Z', diffs: [] }, createdAt: '2025-12-19T11:30:00Z' }, + ], + mergeable: true, + }, +]; + +dummyPullRequests.set('@ether/~genesis/@bob', genesisBobPRs); + +// ---- PR accessor functions ---- + +/** Get PRs registered directly at this path (not nested). */ +export function getPullRequests(canonicalPath: string): PullRequest[] { + return dummyPullRequests.get(canonicalPath) || []; +} + +/** Get ALL PRs at this path and all nested sub-paths. */ +export function getAllPullRequests(canonicalPath: string): PullRequest[] { + const result: PullRequest[] = []; + const prefix = canonicalPath + '/'; + for (const [key, prs] of dummyPullRequests) { + if (key === canonicalPath || key.startsWith(prefix)) { + result.push(...prs); + } + } + return result; +} + +/** A PR paired with its relative folder path (empty string if direct). */ +export interface InlinePR { + pr: PullRequest; + relPath: string; +} + +/** Get PRs for the inline list — includes nested sub-paths but excludes + * world (~) and player (@) sub-paths at any level (those get category rows). */ +export function getInlinePullRequests(canonicalPath: string): InlinePR[] { + const result: InlinePR[] = []; + const prefix = canonicalPath + '/'; + + for (const [key, prs] of dummyPullRequests) { + if (key === canonicalPath) { + for (const pr of prs) result.push({ pr, relPath: '' }); + } else if (key.startsWith(prefix)) { + const rest = key.slice(prefix.length); + const firstSeg = rest.split('/')[0]; + // Always exclude ~ (worlds) and @ (players) sub-paths — they get their own category rows + if (firstSeg.startsWith('~') || firstSeg.startsWith('@')) continue; + for (const pr of prs) result.push({ pr, relPath: rest }); + } + } + return result; +} + +/** Summary of PRs in a category (worlds or players). */ +export interface CategoryPRSummary { + openCount: number; + closedCount: number; + itemCount: number; +} + +/** Get summary of PRs in ~ or @ prefixed sub-paths. Works at any level. */ +export function getCategoryPRSummary(canonicalPath: string, categoryPrefix: '~' | '@'): CategoryPRSummary | null { + const prefix = canonicalPath + '/'; + const items = new Set(); + let openCount = 0; + let closedCount = 0; + + for (const [key, prs] of dummyPullRequests) { + if (key.startsWith(prefix)) { + const rest = key.slice(prefix.length); + const firstSeg = rest.split('/')[0]; + if (firstSeg.startsWith(categoryPrefix)) { + items.add(firstSeg); + openCount += prs.filter(pr => pr.status === 'open').length; + closedCount += prs.filter(pr => pr.status !== 'open').length; + } + } + } + + if (items.size === 0) return null; + return { openCount, closedCount, itemCount: items.size }; +} + +/** Get PRs from a specific category (worlds ~ or players @) for the category list page. */ +export function getCategoryPullRequests(canonicalPath: string, categoryPrefix: '~' | '@'): InlinePR[] { + const result: InlinePR[] = []; + const prefix = canonicalPath + '/'; + + for (const [key, prs] of dummyPullRequests) { + if (key.startsWith(prefix)) { + const rest = key.slice(prefix.length); + const firstSeg = rest.split('/')[0]; + if (firstSeg.startsWith(categoryPrefix)) { + for (const pr of prs) result.push({ pr, relPath: rest }); + } + } + } + return result; +} + +export function getPullRequest(canonicalPath: string, prId: number): PullRequest | null { + // Search at this path first, then nested paths + const direct = getPullRequests(canonicalPath); + const found = direct.find(pr => pr.id === prId); + if (found) return found; + // Search nested (all sub-paths including ~/@ prefixed ones) + const prefix = canonicalPath + '/'; + for (const [key, prs] of dummyPullRequests) { + if (key.startsWith(prefix)) { + const nested = prs.find(pr => pr.id === prId); + if (nested) return nested; + } + } + return null; +} + +/** Open PR count for the PR button — excludes world/player PRs at user root level. */ +export function getOpenPRCount(canonicalPath: string): number { + return getInlinePullRequests(canonicalPath).filter(({ pr }) => pr.status === 'open').length; +} + +let nextPRId = 4; + +export function createPullRequest( + canonicalPath: string, + title: string, + description: string, + sourceLabel: string, + targetLabel: string, + author?: string, +): PullRequest { + const pr: PullRequest = { + id: nextPRId++, + title, + description, + status: 'open', + author: author || 'anonymous', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + sourceVersion: crypto.randomUUID(), + targetVersion: crypto.randomUUID(), + sourceLabel, + targetLabel, + commits: [], + comments: [], + activity: [], + mergeable: true, + }; + const prs = dummyPullRequests.get(canonicalPath) || []; + prs.push(pr); + dummyPullRequests.set(canonicalPath, prs); + return pr; +} diff --git a/@ether/.html/UI/EtherAPI.ts b/@ether/.html/UI/EtherAPI.ts new file mode 100644 index 00000000..712f4de3 --- /dev/null +++ b/@ether/.html/UI/EtherAPI.ts @@ -0,0 +1,82 @@ +// ============================================================ +// EtherAPI.ts — Unified data access layer interface +// ============================================================ +// Backends: DummyBackend (dev), TauriBackend (desktop/mobile), HttpBackend (web/self-hosted) + +import type { + FileEntry, Repository, + PullRequest, InlinePR, CategoryPRSummary, +} from './DummyData.ts'; + +// Re-export types for consumers +export type { + FileEntry, CompoundEntry, TreeEntry, Repository, + PullRequest, PRStatus, FileDiff, PRCommit, PRComment, + ActivityItem, InlinePR, CategoryPRSummary, +} from './DummyData.ts'; + +// ---- Interface ---- + +export interface EtherAPI { + // Directory browsing + listDirectory(path: string): Promise; + readFile(path: string): Promise; + + // Repository-level access + getRepository(user: string): Promise; + getWorld(user: string, world: string): Promise; + getReferencedUsers(user: string, world?: string | null): Promise; + getReferencedWorlds(user: string, world?: string | null): Promise; + + // Pull requests + getPullRequests(canonicalPath: string): Promise; + getPullRequest(canonicalPath: string, prId: number): Promise; + getInlinePullRequests(canonicalPath: string): Promise; + getOpenPRCount(canonicalPath: string): Promise; + getCategoryPRSummary(canonicalPath: string, categoryPrefix: '~' | '@'): Promise; + getCategoryPullRequests(canonicalPath: string, categoryPrefix: '~' | '@'): Promise; + createPullRequest( + canonicalPath: string, + title: string, + description: string, + sourceLabel: string, + targetLabel: string, + author?: string, + ): Promise; +} + +// ---- Singleton + auto-detection ---- + +import { DummyBackend } from './backends/DummyBackend.ts'; +import { TauriBackend } from './backends/TauriBackend.ts'; +import { HttpBackend } from './backends/HttpBackend.ts'; + +let _api: EtherAPI | null = null; + +export function getAPI(): EtherAPI { + if (!_api) _api = detectBackend(); + return _api; +} + +export function setAPI(api: EtherAPI): void { + _api = api; +} + +function detectBackend(): EtherAPI { + // ?dummy query param forces DummyBackend (for testing mock data) + if (typeof location !== 'undefined' && new URLSearchParams(location.search).has('dummy')) { + return new DummyBackend(); + } + + // Tauri injects __TAURI_INTERNALS__ on the window object + if (typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window) { + return new TauriBackend(); + } + + // HTTP backend — works for both production and dev (Vite middleware serves /**/) + if (typeof location !== 'undefined') { + return new HttpBackend(location.origin); + } + + return new DummyBackend(); +} diff --git a/@ether/.html/UI/FileIcons.ts b/@ether/.html/UI/FileIcons.ts new file mode 100644 index 00000000..8b44b5e2 --- /dev/null +++ b/@ether/.html/UI/FileIcons.ts @@ -0,0 +1,116 @@ +// ============================================================ +// FileIcons.ts — SVG icon map by file extension +// ============================================================ + +const svg = (body: string, color: string) => + `${body}`; + +const FOLDER = svg( + ``, + '#8da0cb' +); + +const FILE_GENERIC = svg( + ``, + '#8b8b8b' +); + +const icons: Record = { + md: svg( + ``, + '#519aba' + ), + ts: svg( + ``, + '#3178c6' + ), + js: svg( + ``, + '#f0db4f' + ), + json: svg( + ``, + '#a9a038' + ), + css: svg( + ``, + '#56b3b4' + ), + html: svg( + ``, + '#e44d26' + ), + svg: svg( + ``, + '#e69d2a' + ), + png: svg( + ``, + '#26a269' + ), + jpg: svg( + ``, + '#26a269' + ), + txt: svg( + ``, + '#8b8b8b' + ), + py: svg( + ``, + '#3572A5' + ), + sh: svg( + ``, + '#89e051' + ), + ray: svg( + ``, + '#c084fc' + ), +}; + +export function fileIcon(name: string, isDirectory: boolean): string { + if (isDirectory) return FOLDER; + const ext = name.includes('.') ? name.split('.').pop()!.toLowerCase() : ''; + return icons[ext] || FILE_GENERIC; +} + +// ---- Access permission icons ---- + +const ACCESS_PUBLIC_SVG = ``; + +const ACCESS_LOCAL_SVG = ``; + +const ACCESS_PRIVATE_SVG = ``; + +const ACCESS_NPC_SVG = ``; + +const ACCESS_PLAYER_SVG = ``; + +const EVERYONE_SVG = ``; + +const ACCESS_ENCRYPTED_SVG = ``; + +const ACCESS_ENCRYPTED_SVG_LARGE = ``; + +export function accessSvg(level: string): string { + switch (level) { + case 'local': return ACCESS_LOCAL_SVG; + case 'private': return ACCESS_PRIVATE_SVG; + case 'npc': return ACCESS_NPC_SVG; + case 'player': return ACCESS_PLAYER_SVG; + case 'everyone': return EVERYONE_SVG; + default: return ACCESS_PUBLIC_SVG; + } +} + +export function accessIcon(entry: { access?: string; encrypted?: boolean }): string { + const level = entry.access || 'public'; + return `${accessSvg(level)}`; +} + +export function fileOrEncryptedIcon(entry: { name: string; isDirectory: boolean; encrypted?: boolean }): string { + if (entry.encrypted) return ACCESS_ENCRYPTED_SVG_LARGE; + return fileIcon(entry.name, entry.isDirectory); +} diff --git a/@ether/.html/UI/Greetings.ts b/@ether/.html/UI/Greetings.ts new file mode 100644 index 00000000..3dbdec1d --- /dev/null +++ b/@ether/.html/UI/Greetings.ts @@ -0,0 +1,1033 @@ +// ============================================================ +// Greetings.ts — Onboarding overlay + global command bar for Ether +// mount() = CRT intro overlay (homepage first visit only) +// ensureGlobalBar() = @me button + @/slash listener (all pages) +// ============================================================ + +import { + PHOSPHOR, + CRT_SCREEN_BG, + delay, + fadeOutElement, + typeText, + injectCRTStyles, + createCRT, + turnOnScreen, + dissolveCRT, +} from './CRTShell.ts'; + +// ---- localStorage Helpers ---- + +export function isFirstVisit(): boolean { + try { + return !localStorage.getItem('ether:visited'); + } catch { + return true; + } +} + +function markVisited(): void { + try { + localStorage.setItem('ether:visited', '1'); + } catch {} +} + +function getStoredNames(): string[] { + try { + const raw = localStorage.getItem('ether:names'); + if (raw) return JSON.parse(raw); + const old = localStorage.getItem('ether:name'); + if (old) { + const names = [old]; + localStorage.setItem('ether:names', JSON.stringify(names)); + return names; + } + return []; + } catch { + return []; + } +} + +function getStoredName(): string | null { + const names = getStoredNames(); + return names.length > 0 ? names[names.length - 1] : null; +} + +function storeName(name: string): void { + try { + const names = getStoredNames(); + const idx = names.indexOf(name); + if (idx >= 0) names.splice(idx, 1); + names.push(name); + localStorage.setItem('ether:names', JSON.stringify(names)); + localStorage.setItem('ether:name', name); + window.dispatchEvent(new CustomEvent('ether:character', { detail: name })); + } catch {} +} + +// ---- Global Bar Elements (persist across page navigations) ---- +// The @me button, its styles, and the interaction listener survive page +// transitions. Router.ts controls teardown via teardownGlobalBar(). + +const globalElements: HTMLElement[] = []; +let globalBarStyleEl: HTMLStyleElement | null = null; +let cleanupInteraction: (() => void) | null = null; + +function trackGlobal(el: T): T { + globalElements.push(el); + return el; +} + +// ---- Global Bar Styles ---- +// @me button is global. Command bar overlay styles are scoped to +// .command-overlay so they never leak into repo/user pages. + +function injectGlobalBarStyles(): void { + if (globalBarStyleEl) return; + const s = document.createElement('style'); + globalBarStyleEl = s; + s.textContent = ` + /* ---- @me button (global) ---- */ + .me-button { + position: fixed; + top: 18px; + right: 22px; + font-family: 'Courier New', Courier, monospace; + font-size: 16px; + color: ${PHOSPHOR}; + text-shadow: + 0 0 4px rgba(255,255,255,0.5), + 0 0 11px rgba(255,255,255,0.22); + cursor: pointer; + z-index: 1000; + user-select: none; + background: none; + border: none; + padding: 6px 10px; + white-space: pre; + } + + @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } + .fade-in { animation: fadeIn 0.6s ease-in forwards; } + + /* ---- Command bar overlay (scoped to .command-overlay) ---- */ + .command-overlay .terminal-content { + display: flex; + flex-direction: column; + align-items: center; + max-width: 90%; + max-height: 100%; + overflow: hidden; + touch-action: none; + } + + .command-overlay .t { + color: ${PHOSPHOR}; + text-shadow: + 0 0 4px rgba(255,255,255,0.5), + 0 0 11px rgba(255,255,255,0.22), + -0.4px 0 rgba(255,80,80,0.07), + 0.4px 0 rgba(80,80,255,0.07); + } + + .command-overlay .t-muted { + color: rgba(255,255,255,0.35); + text-shadow: none; + } + + @keyframes cursorPulse { + 0%, 49% { opacity: 1; } + 50%, 100% { opacity: 0; } + } + .command-overlay .cursor { + display: inline-block; + width: 0.55em; + height: 1em; + background: ${PHOSPHOR}; + box-shadow: 0 0 8px rgba(255,255,255,0.5); + position: relative; + top: 0.22em; + animation: cursorPulse 1s step-end infinite; + } + + .command-overlay .input-row { + display: flex; + align-items: baseline; + font-family: 'Courier New', Courier, monospace; + font-size: 18px; + line-height: 1.8; + white-space: pre; + } + + .command-overlay .hint { + margin-top: 14px; + font-family: 'Courier New', Courier, monospace; + font-size: 13px; + color: rgba(255,255,255,0.3); + } + + .command-overlay .hidden-input { + position: fixed; + left: -9999px; + top: 0; + width: 200px; + height: 40px; + font-size: 16px; + opacity: 0; + border: none; + padding: 0; + } + + .command-overlay .history-container { + display: flex; + flex-direction: column; + height: 70vh; + } + + .command-overlay .history-above, + .command-overlay .history-below { + flex: 1; + min-height: 0; + overflow-y: auto; + display: flex; + flex-direction: column; + align-items: flex-start; + -webkit-overflow-scrolling: touch; + touch-action: pan-y; + overscroll-behavior: contain; + scrollbar-width: thin; + scrollbar-color: rgba(255,255,255,0.1) transparent; + } + .command-overlay .history-above::-webkit-scrollbar, + .command-overlay .history-below::-webkit-scrollbar { width: 4px; } + .command-overlay .history-above::-webkit-scrollbar-track, + .command-overlay .history-below::-webkit-scrollbar-track { background: transparent; } + .command-overlay .history-above::-webkit-scrollbar-thumb, + .command-overlay .history-below::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 2px; } + + .command-overlay .history-above { + justify-content: flex-end; + } + + .command-overlay .history-entry { + display: flex; + align-items: baseline; + font-family: 'Courier New', Courier, monospace; + font-size: 18px; + line-height: 1.8; + white-space: pre; + color: rgba(255,255,255,0.35); + text-shadow: none; + cursor: pointer; + transition: color 0.15s; + flex-shrink: 0; + } + .command-overlay .history-entry:hover { + color: rgba(255,255,255,0.55); + } + + .command-overlay .history-spacer { + visibility: hidden; + } + `; + document.head.appendChild(s); + trackGlobal(s); +} + +// ---- Centered Overlay (.command-overlay scoped) ---- + +function createCenteredOverlay(): { wrapper: HTMLElement; content: HTMLElement } { + const wrapper = document.createElement('div'); + wrapper.className = 'command-overlay'; + wrapper.style.cssText = `position:fixed;inset:0;display:flex;align-items:center;justify-content:center;z-index:50;background:rgba(10,10,10,0.85);opacity:0;transition:opacity 0.35s ease-in;overflow:hidden;touch-action:none;`; + + const content = document.createElement('div'); + content.className = 'terminal-content'; + wrapper.appendChild(content); + document.body.appendChild(wrapper); + + // Force reflow then fade in + wrapper.offsetHeight; + wrapper.style.opacity = '1'; + + return { wrapper, content }; +} + +async function fadeOutOverlay(wrapper: HTMLElement): Promise { + wrapper.style.transition = 'opacity 0.3s ease-out'; + wrapper.style.opacity = '0'; + await delay(300); + wrapper.remove(); +} + +// ---- Phase: Intro (Ether glitch only) ---- + +async function introPhase(content: HTMLElement): Promise { + const w1 = document.createElement('div'); + content.appendChild(w1); + const etherSpan = await typeText(w1, 'Ether', { speed: 130, large: true }); + await delay(1000); + + const logo = document.createElement('img'); + logo.src = 'images/avatar/2d.svg'; + logo.className = 'ether-logo'; + logo.draggable = false; + logo.style.width = etherSpan.offsetWidth + 'px'; + + etherSpan.classList.add('glitch-shake'); + await delay(150); + + etherSpan.style.display = 'none'; + w1.appendChild(logo); + logo.classList.add('glitch-shake'); + await delay(60); + + logo.classList.remove('glitch-shake'); + await delay(250); + + logo.classList.add('glitch-shake'); + await delay(120); + logo.remove(); + etherSpan.style.display = ''; + await delay(60); + + etherSpan.classList.remove('glitch-shake'); + await delay(600); + await fadeOutElement(w1); + await delay(350); +} + +// ---- Phase: Name Input (with typewriter prompt) ---- + +async function nameInputPhase(content: HTMLElement): Promise<{ name: string; row: HTMLElement }> { + const row = document.createElement('div'); + row.className = 'input-row'; + content.appendChild(row); + + const promptSpan = document.createElement('span'); + promptSpan.className = 't'; + row.appendChild(promptSpan); + + const promptText = '@me = @'; + for (let i = 0; i < promptText.length; i++) { + promptSpan.textContent += promptText[i]; + await delay(48 + Math.random() * 22); + } + + const placeholder = document.createElement('span'); + placeholder.className = 't-muted'; + row.appendChild(placeholder); + + const cursor = document.createElement('span'); + cursor.className = 'cursor'; + row.appendChild(cursor); + + const placeholderText = '[Your Avatar Name]'; + for (let i = 0; i < placeholderText.length; i++) { + placeholder.textContent += placeholderText[i]; + await delay(30 + Math.random() * 18); + } + + const nameSpan = document.createElement('span'); + nameSpan.className = 't'; + row.insertBefore(nameSpan, placeholder); + row.insertBefore(cursor, placeholder); + + const hint = document.createElement('div'); + hint.className = 'hint'; + hint.textContent = 'Press enter to be anonymous.'; + content.appendChild(hint); + + const name = (await waitForNameInput(content, nameSpan, placeholder, hint, cursor, '')) ?? 'anonymous'; + + return { name, row }; +} + +// ---- Quick Name Input (instant prompt, pre-filled, for re-edit) ---- + +async function nameInputQuick(content: HTMLElement, currentName: string): Promise<{ name: string; row: HTMLElement } | null> { + const row = document.createElement('div'); + row.className = 'input-row'; + content.appendChild(row); + + const promptSpan = document.createElement('span'); + promptSpan.className = 't'; + row.appendChild(promptSpan); + + const nameSpan = document.createElement('span'); + nameSpan.className = 't'; + nameSpan.textContent = currentName === 'anonymous' ? '@' : `@${currentName}`; + row.appendChild(nameSpan); + + const cursor = document.createElement('span'); + cursor.className = 'cursor'; + row.appendChild(cursor); + + // Type the prompt in front — the @ is already shown as part of the name + const promptText = '@me = '; + for (let i = 0; i < promptText.length; i++) { + promptSpan.textContent += promptText[i]; + await delay(48 + Math.random() * 22); + } + + // Silently move the @ from nameSpan into promptSpan so input syncs correctly + promptSpan.textContent += '@'; + const prefill = currentName === 'anonymous' ? '' : currentName; + nameSpan.textContent = prefill; + + const name = await waitForNameInput(content, nameSpan, null, null, cursor, prefill, currentName); + if (name === null) return null; + + return { name, row }; +} + +// ---- Shared: hidden input + Enter/Escape + history navigation ---- + +async function waitForNameInput( + content: HTMLElement, + nameSpan: HTMLElement, + placeholder: HTMLElement | null, + hint: HTMLElement | null, + cursor: HTMLElement, + prefill: string, + excludeFromHistory?: string, +): Promise { + const row = nameSpan.closest('.input-row') as HTMLElement; + + // -- History UI -- + const allNames = getStoredNames(); + const history = allNames + .filter(n => n !== (excludeFromHistory ?? '')) + .reverse(); // most recent first + + let aboveDiv: HTMLElement | null = null; + let belowDiv: HTMLElement | null = null; + let historyContainer: HTMLElement | null = null; + + function makeHistoryEntry(name: string, empty = false): HTMLElement { + const entry = document.createElement('div'); + entry.className = 'history-entry'; + const spacer = document.createElement('span'); + spacer.className = 'history-spacer'; + spacer.textContent = '@me = '; + entry.appendChild(spacer); + const textSpan = document.createElement('span'); + if (!empty) textSpan.textContent = `@${name}`; + entry.appendChild(textSpan); + entry.dataset.name = name; + return entry; + } + + async function typeInHistoryEntry(entry: HTMLElement, name: string): Promise { + const textSpan = entry.lastElementChild as HTMLElement; + const text = `@${name}`; + for (let i = 0; i < text.length; i++) { + textSpan.textContent += text[i]; + await delay(25 + Math.random() * 15); + } + } + + if (history.length > 0) { + historyContainer = document.createElement('div'); + historyContainer.className = 'history-container'; + + // Measure the longest possible row for stable centering. + // Wrap sizer in .command-overlay so scoped styles apply. + const sizerWrap = document.createElement('div'); + sizerWrap.className = 'command-overlay'; + sizerWrap.style.cssText = 'position:absolute;visibility:hidden;pointer-events:none;'; + const sizer = document.createElement('div'); + sizer.className = 'input-row'; + const longestName = [prefill, ...history].reduce((a, b) => a.length > b.length ? a : b, ''); + const sizerText = document.createElement('span'); + sizerText.className = 't'; + sizerText.textContent = `@me = @${longestName}`; + sizer.appendChild(sizerText); + const sizerCursor = document.createElement('span'); + sizerCursor.className = 'cursor'; + sizer.appendChild(sizerCursor); + sizerWrap.appendChild(sizer); + document.body.appendChild(sizerWrap); + historyContainer.style.minWidth = sizer.offsetWidth + 'px'; + sizerWrap.remove(); + + aboveDiv = document.createElement('div'); + aboveDiv.className = 'history-above'; + + belowDiv = document.createElement('div'); + belowDiv.className = 'history-below'; + + historyContainer.appendChild(aboveDiv); + // Move the row into the container (stays centered between above/below) + content.insertBefore(historyContainer, row); + row.style.flexShrink = '0'; + historyContainer.appendChild(row); + historyContainer.appendChild(belowDiv); + + for (const name of history) { + const entry = makeHistoryEntry(name, true); + belowDiv.appendChild(entry); + typeInHistoryEntry(entry, name); // fire-and-forget, all type in parallel + } + } + + // -- Hidden input -- + const hiddenInput = document.createElement('input'); + hiddenInput.className = 'hidden-input'; + hiddenInput.type = 'text'; + hiddenInput.autocomplete = 'off'; + hiddenInput.autocapitalize = 'off'; + hiddenInput.spellcheck = false; + hiddenInput.value = prefill; + content.appendChild(hiddenInput); + hiddenInput.focus(); + + function refocus(e: Event) { + const t = e.target as HTMLElement; + if (t.closest('.history-above') || t.closest('.history-below')) return; + hiddenInput.focus(); + } + document.addEventListener('click', refocus); + document.addEventListener('touchstart', refocus, { passive: true }); + + // -- History state -- + let historyPos = -1; // -1 = typing new text + let savedTypedText = prefill; + + function setInputValue(val: string) { + hiddenInput.value = val; + nameSpan.textContent = val; + if (placeholder) placeholder.style.display = val ? 'none' : ''; + if (hint) hint.style.display = val ? 'none' : ''; + } + + // Navigate deeper into history (entries move from below to above) + function navigateDeeper() { + if (!belowDiv || !aboveDiv) return; + if (historyPos >= history.length - 1) return; + + if (historyPos === -1) { + savedTypedText = hiddenInput.value; + if (savedTypedText) { + aboveDiv.appendChild(makeHistoryEntry(savedTypedText)); + } + } else { + aboveDiv.appendChild(makeHistoryEntry(history[historyPos])); + } + + historyPos++; + + const first = belowDiv.firstElementChild; + if (first) first.remove(); + + setInputValue(history[historyPos]); + } + + // Navigate shallower (entries move from above back to below) + function navigateShallower() { + if (!belowDiv || !aboveDiv) return; + if (historyPos <= -1) return; + + belowDiv.insertBefore(makeHistoryEntry(history[historyPos]), belowDiv.firstChild); + + historyPos--; + + const last = aboveDiv.lastElementChild; + if (last) last.remove(); + + if (historyPos === -1) { + setInputValue(savedTypedText); + } else { + setInputValue(history[historyPos]); + } + } + + // -- Input sync -- + hiddenInput.addEventListener('input', () => { + const val = hiddenInput.value; + nameSpan.textContent = val; + if (placeholder) placeholder.style.display = val ? 'none' : ''; + if (hint) hint.style.display = val ? 'none' : ''; + }); + + // -- Tap to select history entry -- + function onHistoryClick(e: Event) { + const target = (e.target as HTMLElement).closest('.history-entry') as HTMLElement | null; + if (!target) return; + const name = target.dataset.name!; + setInputValue(name); + hiddenInput.focus(); + } + let touchCleanup: (() => void) | null = null; + if (historyContainer) { + historyContainer.addEventListener('click', onHistoryClick); + + // -- Swipe / scroll wheel to navigate history -- + let touchStartY = 0; + let touchAccum = 0; + const SWIPE_THRESHOLD = 40; + + function onTouchStart(e: TouchEvent) { + touchStartY = e.touches[0].clientY; + touchAccum = 0; + } + function onTouchMove(e: TouchEvent) { + e.preventDefault(); + const dy = touchStartY - e.touches[0].clientY; + const prev = touchAccum; + touchAccum = dy; + const prevStep = Math.floor(prev / SWIPE_THRESHOLD); + const curStep = Math.floor(touchAccum / SWIPE_THRESHOLD); + if (curStep > prevStep) { + navigateDeeper(); + hiddenInput.focus(); + } else if (curStep < prevStep) { + navigateShallower(); + hiddenInput.focus(); + } + } + function onWheel(e: WheelEvent) { + e.preventDefault(); + if (e.deltaY > 0) { + navigateDeeper(); + } else if (e.deltaY < 0) { + navigateShallower(); + } + hiddenInput.focus(); + } + historyContainer.addEventListener('touchstart', onTouchStart, { passive: true }); + historyContainer.addEventListener('touchmove', onTouchMove, { passive: false }); + document.addEventListener('wheel', onWheel, { passive: false }); + touchCleanup = () => { + historyContainer!.removeEventListener('touchstart', onTouchStart); + historyContainer!.removeEventListener('touchmove', onTouchMove); + document.removeEventListener('wheel', onWheel); + }; + } + + // -- Wait for Enter or Escape -- + const escaped = await new Promise((resolve) => { + function onMouseDown(e: MouseEvent) { + if (e.button === 1) { + e.preventDefault(); + hiddenInput.removeEventListener('keydown', onKeyDown); + document.removeEventListener('mousedown', onMouseDown); + resolve(false); + } + } + document.addEventListener('mousedown', onMouseDown); + + function onKeyDown(e: KeyboardEvent) { + if (e.key === 'Enter') { + e.preventDefault(); + hiddenInput.removeEventListener('keydown', onKeyDown); + document.removeEventListener('mousedown', onMouseDown); + resolve(false); + } else if (e.key === 'Escape') { + e.preventDefault(); + hiddenInput.removeEventListener('keydown', onKeyDown); + document.removeEventListener('mousedown', onMouseDown); + resolve(true); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + navigateDeeper(); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + navigateShallower(); + } + } + hiddenInput.addEventListener('keydown', onKeyDown); + }); + + // Capture value before cleanup removes input + const finalValue = hiddenInput.value.trim(); + + // -- Cleanup -- + document.removeEventListener('click', refocus); + document.removeEventListener('touchstart', refocus); + if (historyContainer) { + if (touchCleanup) touchCleanup(); + historyContainer.removeEventListener('click', onHistoryClick); + row.style.flexShrink = ''; + content.insertBefore(row, historyContainer); + historyContainer.remove(); + } + cursor.remove(); + if (placeholder) placeholder.remove(); + if (hint) hint.remove(); + hiddenInput.remove(); + + if (escaped) return null; + + if (!finalValue) nameSpan.textContent = 'anonymous'; + return finalValue || 'anonymous'; +} + +// ---- Instant Name Input (no typewriter, for command bar @me= transition) ---- + +async function nameInputInstant( + content: HTMLElement, + currentName: string, +): Promise<{ name: string } | null> { + const row = document.createElement('div'); + row.className = 'input-row'; + content.appendChild(row); + + const promptSpan = document.createElement('span'); + promptSpan.className = 't'; + promptSpan.textContent = '@me = @'; + row.appendChild(promptSpan); + + const nameSpan = document.createElement('span'); + nameSpan.className = 't'; + nameSpan.textContent = currentName === 'anonymous' ? '' : currentName; + row.appendChild(nameSpan); + + const cursor = document.createElement('span'); + cursor.className = 'cursor'; + row.appendChild(cursor); + + const prefill = currentName === 'anonymous' ? '' : currentName; + const name = await waitForNameInput(content, nameSpan, null, null, cursor, prefill, currentName); + if (name === null) return null; + + return { name }; +} + +// ---- Command Bar: Global @/slash Input Overlay ---- + +async function openCommandBar( + initialText: string, + currentName: string, +): Promise<{ type: 'navigate' | 'name' | 'cancel'; value: string }> { + const { wrapper, content } = createCenteredOverlay(); + + const row = document.createElement('div'); + row.className = 'input-row'; + content.appendChild(row); + + const textSpan = document.createElement('span'); + textSpan.className = 't'; + textSpan.textContent = initialText; + row.appendChild(textSpan); + + const cursor = document.createElement('span'); + cursor.className = 'cursor'; + row.appendChild(cursor); + + const hiddenInput = document.createElement('input'); + hiddenInput.className = 'hidden-input'; + hiddenInput.type = 'text'; + hiddenInput.autocomplete = 'off'; + hiddenInput.autocapitalize = 'off'; + hiddenInput.spellcheck = false; + hiddenInput.value = initialText; + content.appendChild(hiddenInput); + hiddenInput.focus(); + hiddenInput.setSelectionRange(initialText.length, initialText.length); + + function refocus(e: Event) { + const t = e.target as HTMLElement; + if (t.closest('.history-above') || t.closest('.history-below')) return; + hiddenInput.focus(); + } + document.addEventListener('click', refocus); + document.addEventListener('touchstart', refocus, { passive: true }); + + const ME_PATTERN = /^@me\s*=\s*/i; + + const result = await new Promise<{ type: 'navigate' | 'name' | 'cancel'; value: string }>((resolve) => { + let nameEditActive = false; + + function onInput() { + const val = hiddenInput.value; + textSpan.textContent = val; + + if (!nameEditActive && ME_PATTERN.test(val)) { + nameEditActive = true; + hiddenInput.removeEventListener('keydown', onKeyDown); + hiddenInput.removeEventListener('input', onInput); + document.removeEventListener('click', refocus); + document.removeEventListener('touchstart', refocus); + textSpan.remove(); + cursor.remove(); + hiddenInput.remove(); + row.remove(); + + nameInputInstant(content, currentName).then((nameResult) => { + if (nameResult) { + resolve({ type: 'name', value: nameResult.name }); + } else { + resolve({ type: 'cancel', value: '' }); + } + }); + } + } + + function onKeyDown(e: KeyboardEvent) { + if (e.key === 'Enter') { + e.preventDefault(); + hiddenInput.removeEventListener('keydown', onKeyDown); + hiddenInput.removeEventListener('input', onInput); + resolve({ type: 'navigate', value: hiddenInput.value }); + } else if (e.key === 'Escape') { + e.preventDefault(); + hiddenInput.removeEventListener('keydown', onKeyDown); + hiddenInput.removeEventListener('input', onInput); + resolve({ type: 'cancel', value: '' }); + } + } + + hiddenInput.addEventListener('input', onInput); + hiddenInput.addEventListener('keydown', onKeyDown); + }); + + document.removeEventListener('click', refocus); + document.removeEventListener('touchstart', refocus); + await fadeOutOverlay(wrapper); + + return result; +} + +// ---- FLIP Animation: source element → top-right button ---- + +async function animateMeToCorner(sourceEl: HTMLElement, name: string): Promise { + const sourceRect = sourceEl.getBoundingClientRect(); + + const btn = trackGlobal(document.createElement('button')); + btn.className = 'me-button'; + btn.textContent = `@${name}`; + document.body.appendChild(btn); + + const btnRect = btn.getBoundingClientRect(); + + const dx = sourceRect.left - btnRect.left; + const dy = sourceRect.top - btnRect.top; + + btn.style.transform = `translate(${dx}px, ${dy}px)`; + btn.style.transition = 'none'; + + sourceEl.style.visibility = 'hidden'; + + // Force reflow + btn.offsetHeight; + + btn.style.transition = 'transform 0.8s cubic-bezier(0.22, 1, 0.36, 1)'; + btn.style.transform = 'translate(0, 0)'; + + await delay(850); + + btn.style.transition = ''; + btn.style.transform = ''; + sourceEl.remove(); + + return btn; +} + +// ---- Animate button from top-right to screen center ---- + +async function animateButtonToCenter(btn: HTMLElement): Promise { + const btnRect = btn.getBoundingClientRect(); + const cx = window.innerWidth / 2 - btnRect.width / 2; + const cy = window.innerHeight / 2 - btnRect.height / 2; + const dx = cx - btnRect.left; + const dy = cy - btnRect.top; + + btn.style.transition = 'transform 0.6s cubic-bezier(0.22, 1, 0.36, 1)'; + btn.style.transform = `translate(${dx}px, ${dy}px)`; + + await delay(650); +} + +// ---- Show @me button in top-right ---- + +function showMeButton(name: string): HTMLElement { + const btn = trackGlobal(document.createElement('button')); + btn.className = 'me-button fade-in'; + btn.textContent = `@${name}`; + document.body.appendChild(btn); + return btn; +} + +// ---- Interaction: click button or global @/slash key ---- + +function installInteraction(btn: HTMLElement, currentName: string): () => void { + let active = true; + + async function onBtnClick() { + if (!active) return; + deactivate(); + + await animateButtonToCenter(btn); + btn.remove(); + + const { wrapper, content } = createCenteredOverlay(); + const result = await nameInputQuick(content, currentName); + + if (result) { + storeName(result.name); + currentName = result.name; + const [newBtn] = await Promise.all([ + animateMeToCorner(result.row, result.name), + fadeOutOverlay(wrapper), + ]); + cleanupInteraction = installInteraction(newBtn, currentName); + } else { + // Escape — recreate button in corner + await fadeOutOverlay(wrapper); + const newBtn = showMeButton(currentName); + cleanupInteraction = installInteraction(newBtn, currentName); + } + } + + function onGlobalKeyDown(e: KeyboardEvent) { + if (!active) return; + const tag = (document.activeElement?.tagName || '').toLowerCase(); + if (tag === 'input' || tag === 'textarea' || (document.activeElement as HTMLElement)?.isContentEditable) return; + + if (e.key === '@' || e.key === '/' || e.key === '#') { + e.preventDefault(); + handleCommandBar(e.key); + } + } + + async function handleCommandBar(initialText: string) { + deactivate(); + + const result = await openCommandBar(initialText, currentName); + + if (result.type === 'navigate') { + const mapped = result.value.replace(/#/g, '~'); + const path = mapped.startsWith('/') ? mapped : '/' + mapped; + history.pushState(null, '', path); + dispatchEvent(new PopStateEvent('popstate')); + // Reactivate — the global bar persists across page transitions. + // If teardownGlobalBar() was called during navigation, btn is + // disconnected and activate() becomes a no-op. + activate(); + return; + } + + if (result.type === 'name') { + storeName(result.value); + currentName = result.value; + btn.textContent = `@${result.value}`; + } + + activate(); + } + + function activate() { + if (!btn.isConnected) return; + active = true; + btn.addEventListener('click', onBtnClick, { once: true }); + document.addEventListener('keydown', onGlobalKeyDown); + } + + function deactivate() { + active = false; + btn.removeEventListener('click', onBtnClick); + document.removeEventListener('keydown', onGlobalKeyDown); + } + + activate(); + + return deactivate; +} + +// ---- Public API ---- + +/** CRT intro overlay (homepage first visit only). */ +export async function mount(): Promise { + const crtStyle = injectCRTStyles(); + injectGlobalBarStyles(); + + const crt = createCRT(); + + // CRT now covers the page — safe to hide it + const page = document.getElementById('root'); + if (page) page.style.opacity = '0'; + + await delay(700); + await turnOnScreen(crt.screen); + await delay(500); + + dissolveCRT(crt); + await introPhase(crt.content); + + // Intro done — fade the CRT background to semi-transparent during name input. + // Page stays hidden (rendered after mount resolves with the chosen name). + crt.crt.style.transition = 'background 1s ease-out'; + crt.crt.style.background = 'rgba(10,10,10,0.85)'; + crt.screen.style.transition = 'background 1s ease-out'; + crt.screen.style.background = 'transparent'; + + const { name, row } = await nameInputPhase(crt.content); + + storeName(name); + markVisited(); + + // --- Simultaneous: CRT fades away + name button flies to corner --- + + // 1. Measure the row while it's still inside the CRT + const sourceRect = row.getBoundingClientRect(); + + const btn = trackGlobal(document.createElement('button')); + btn.className = 'me-button'; + btn.textContent = `@${name}`; + document.body.appendChild(btn); + + const btnRect = btn.getBoundingClientRect(); + const dx = sourceRect.left - btnRect.left; + const dy = sourceRect.top - btnRect.top; + + btn.style.transform = `translate(${dx}px, ${dy}px)`; + btn.style.transition = 'none'; + row.style.visibility = 'hidden'; + + // 2. Force reflow so the browser commits btn at offset position + btn.offsetHeight; + + // 3. Fade out CRT overlay + animate button simultaneously + crt.crt.style.transition = 'opacity 0.8s ease-out'; + crt.crt.style.opacity = '0'; + btn.style.transition = 'transform 0.8s cubic-bezier(0.22, 1, 0.36, 1)'; + btn.style.transform = 'translate(0, 0)'; + + await delay(850); + + // 4. Clean up — leave page hidden (caller fades it in after rendering) + crt.crt.remove(); + if (crtStyle) crtStyle.remove(); + document.body.style.background = ''; + document.body.style.transition = ''; + btn.style.transition = ''; + btn.style.transform = ''; + row.remove(); + + cleanupInteraction = installInteraction(btn, name); +} + +/** Set up @me button + command bar on any page (returning visitors). */ +export function ensureGlobalBar(): void { + if (isGlobalBarActive()) return; + const storedName = getStoredName(); + if (!storedName) return; + injectGlobalBarStyles(); + const btn = showMeButton(storedName); + cleanupInteraction = installInteraction(btn, storedName); +} + +/** Remove global bar entirely (button + listener + styles). */ +export function teardownGlobalBar(): void { + if (cleanupInteraction) { cleanupInteraction(); cleanupInteraction = null; } + for (const el of globalElements) { + el.remove(); + } + globalElements.length = 0; + globalBarStyleEl = null; +} + +export function isGlobalBarActive(): boolean { + return cleanupInteraction !== null; +} diff --git a/@ether/.html/UI/IDELayout.ts b/@ether/.html/UI/IDELayout.ts new file mode 100644 index 00000000..b64895f1 --- /dev/null +++ b/@ether/.html/UI/IDELayout.ts @@ -0,0 +1,1493 @@ +// ============================================================ +// IDELayout.ts — Vanilla TypeScript IDE layout engine +// Ported from React IDELayout.tsx to vanilla DOM. +// ============================================================ + +import { PHOSPHOR, CRT_SCREEN_BG } from './CRTShell.ts'; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface SplitNode { + type: 'split'; + id: string; + direction: 'horizontal' | 'vertical'; + children: LayoutNode[]; + sizes: number[]; +} + +export interface TabGroupNode { + type: 'tabgroup'; + id: string; + panels: string[]; + activeIndex: number; +} + +export type LayoutNode = SplitNode | TabGroupNode; + +export interface PanelDefinition { + id: string; + title: string; + icon?: string; // SVG string for tab icon + closable: boolean; + sticky?: boolean; // if true, the containing tabgroup becomes sticky (viewport-pinned with own scroll) + render: (container: HTMLElement) => (() => void) | void; // returns cleanup +} + +export type DropZoneType = 'tab' | 'left' | 'right' | 'top' | 'bottom'; + +interface DropZone { + targetId: string; + type: DropZoneType; + insertIndex?: number; +} + +interface DragState { + panelId: string; + sourceGroupId: string; +} + +interface CollapseRecord { + panels: string[]; + removedGroupId: string; + originalSize: number; + originalIndex: number; + parentSplitId: string; + targetGroupId: string; + insertPosition: 'before' | 'after'; + originalActiveIndex: number; + collapsedAtDim: number; + direction: 'horizontal' | 'vertical'; +} + +const MIN_COLLAPSE_HORIZONTAL_PX = 120; +const MIN_COLLAPSE_VERTICAL_PX = 120; +const RESTORE_HYSTERESIS = 60; +const HANDLE_SIZE = 4; + +// ─── ID Generation ─────────────────────────────────────────────────────────── + +let _idCounter = 0; +export function generateId(): string { + return `ide-${++_idCounter}`; +} + +/** Ensure counter is at least `min` (call after restoring saved layout IDs). */ +export function ensureIdCounter(min: number): void { + if (_idCounter < min) _idCounter = min; +} + +// ─── Tree Utilities ────────────────────────────────────────────────────────── + +export function findNode(root: LayoutNode, id: string): LayoutNode | null { + if (root.id === id) return root; + if (root.type === 'split') { + for (const child of root.children) { + const found = findNode(child, id); + if (found) return found; + } + } + return null; +} + +export function findParent( + root: LayoutNode, + id: string +): { parent: SplitNode; index: number } | null { + if (root.type === 'split') { + for (let i = 0; i < root.children.length; i++) { + if (root.children[i].id === id) { + return { parent: root, index: i }; + } + const found = findParent(root.children[i], id); + if (found) return found; + } + } + return null; +} + +export function replaceNode( + root: LayoutNode, + targetId: string, + replacement: LayoutNode +): LayoutNode { + if (root.id === targetId) return replacement; + if (root.type === 'split') { + return { + ...root, + children: root.children.map((child) => + replaceNode(child, targetId, replacement) + ), + }; + } + return root; +} + +export function removePanelFromNode( + root: LayoutNode, + groupId: string, + panelId: string +): LayoutNode { + if (root.type === 'tabgroup' && root.id === groupId) { + const newPanels = root.panels.filter((p) => p !== panelId); + const newActive = Math.min(root.activeIndex, Math.max(0, newPanels.length - 1)); + return { ...root, panels: newPanels, activeIndex: newActive }; + } + if (root.type === 'split') { + return { + ...root, + children: root.children.map((child) => + removePanelFromNode(child, groupId, panelId) + ), + }; + } + return root; +} + +export function normalizeTree(node: LayoutNode): LayoutNode | null { + if (node.type === 'tabgroup') { + return node.panels.length === 0 ? null : node; + } + + const normalizedChildren: LayoutNode[] = []; + const normalizedSizes: number[] = []; + for (let i = 0; i < node.children.length; i++) { + const result = normalizeTree(node.children[i]); + if (result) { + normalizedChildren.push(result); + normalizedSizes.push(node.sizes[i]); + } + } + + if (normalizedChildren.length === 0) return null; + if (normalizedChildren.length === 1) return normalizedChildren[0]; + + const sizeSum = normalizedSizes.reduce((a, b) => a + b, 0); + const correctedSizes = normalizedSizes.map((s) => s / sizeSum); + + // Flatten same-direction nested splits + const flatChildren: LayoutNode[] = []; + const flatSizes: number[] = []; + for (let i = 0; i < normalizedChildren.length; i++) { + const child = normalizedChildren[i]; + if (child.type === 'split' && child.direction === node.direction) { + for (let j = 0; j < child.children.length; j++) { + flatChildren.push(child.children[j]); + flatSizes.push(correctedSizes[i] * child.sizes[j]); + } + } else { + flatChildren.push(child); + flatSizes.push(correctedSizes[i]); + } + } + + return { + ...node, + children: flatChildren, + sizes: flatSizes, + }; +} + +export function findPanelInLayout( + root: LayoutNode, + panelId: string +): { groupId: string; index: number } | null { + if (root.type === 'tabgroup') { + const idx = root.panels.indexOf(panelId); + if (idx !== -1) return { groupId: root.id, index: idx }; + return null; + } + if (root.type === 'split') { + for (const child of root.children) { + const found = findPanelInLayout(child, panelId); + if (found) return found; + } + } + return null; +} + +// ─── Responsive Collapse Helpers ───────────────────────────────────────────── + +function findEdgeTabGroup( + node: LayoutNode, + side: 'left' | 'right' +): TabGroupNode | null { + if (node.type === 'tabgroup') return node; + if (node.type === 'split') { + if (node.children.length === 0) return null; + const idx = side === 'left' ? 0 : node.children.length - 1; + return findEdgeTabGroup(node.children[idx], side); + } + return null; +} + +function findSmallestBelowThreshold( + node: LayoutNode, + availWidth: number, + availHeight: number +): { childNode: LayoutNode; parentSplit: SplitNode; childIndex: number } | null { + if (node.type !== 'split') return null; + + const isHoriz = node.direction === 'horizontal'; + const dim = isHoriz ? availWidth : availHeight; + const threshold = isHoriz ? MIN_COLLAPSE_HORIZONTAL_PX : MIN_COLLAPSE_VERTICAL_PX; + const handleSpace = (node.children.length - 1) * HANDLE_SIZE; + const contentSpace = dim - handleSpace; + + // Recurse into children first (collapse deepest levels first) + for (let i = 0; i < node.children.length; i++) { + const childDim = contentSpace * node.sizes[i]; + const childW = isHoriz ? childDim : availWidth; + const childH = isHoriz ? availHeight : childDim; + const deeper = findSmallestBelowThreshold(node.children[i], childW, childH); + if (deeper) return deeper; + } + + // Check this level + if (node.children.length <= 1) return null; + + let smallestIndex = -1; + let smallestPx = Infinity; + + for (let i = 0; i < node.children.length; i++) { + const childPx = contentSpace * node.sizes[i]; + if ( + node.children[i].type === 'tabgroup' && + childPx < threshold && + childPx <= smallestPx + ) { + smallestPx = childPx; + smallestIndex = i; + } + } + + if (smallestIndex !== -1) { + return { + childNode: node.children[smallestIndex], + parentSplit: node, + childIndex: smallestIndex, + }; + } + + return null; +} + +function performSingleCollapse( + tree: LayoutNode, + target: { childNode: LayoutNode; parentSplit: SplitNode; childIndex: number }, + containerWidth: number, + containerHeight: number +): { tree: LayoutNode; record: CollapseRecord } | null { + const { childNode, parentSplit, childIndex } = target; + if (childNode.type !== 'tabgroup') return null; + if (parentSplit.children.length <= 1) return null; + + let largestIndex = -1; + let largestSize = -1; + for (let i = 0; i < parentSplit.children.length; i++) { + if (i === childIndex) continue; + if (parentSplit.sizes[i] > largestSize) { + largestSize = parentSplit.sizes[i]; + largestIndex = i; + } + } + if (largestIndex === -1) return null; + + const isFromLeft = childIndex < largestIndex; + const targetTabGroup = findEdgeTabGroup( + parentSplit.children[largestIndex], + isFromLeft ? 'left' : 'right' + ); + if (!targetTabGroup) return null; + + const currentTarget = findNode(tree, targetTabGroup.id) as TabGroupNode; + if (!currentTarget || currentTarget.type !== 'tabgroup') return null; + + const newPanels = isFromLeft + ? [...childNode.panels, ...currentTarget.panels] + : [...currentTarget.panels, ...childNode.panels]; + + const newActiveIndex = isFromLeft + ? childNode.panels.length + currentTarget.activeIndex + : currentTarget.activeIndex; + + let newTree = replaceNode(tree, targetTabGroup.id, { + ...currentTarget, + panels: newPanels, + activeIndex: newActiveIndex, + }); + + const currentParent = findNode(newTree, parentSplit.id) as SplitNode; + if (!currentParent || currentParent.type !== 'split') return null; + + const newChildren = currentParent.children.filter((_, i) => i !== childIndex); + const newSizes = currentParent.sizes.filter((_, i) => i !== childIndex); + const sizeSum = newSizes.reduce((a, b) => a + b, 0); + const normalizedSizes = newSizes.map((s) => s / sizeSum); + + newTree = replaceNode(newTree, parentSplit.id, { + ...currentParent, + children: newChildren, + sizes: normalizedSizes, + }); + + const dim = + parentSplit.direction === 'horizontal' ? containerWidth : containerHeight; + + const record: CollapseRecord = { + panels: childNode.panels, + removedGroupId: childNode.id, + originalSize: parentSplit.sizes[childIndex], + originalIndex: childIndex, + parentSplitId: parentSplit.id, + targetGroupId: targetTabGroup.id, + insertPosition: isFromLeft ? 'before' : 'after', + originalActiveIndex: childNode.activeIndex, + collapsedAtDim: dim, + direction: parentSplit.direction, + }; + + return { tree: newTree, record }; +} + +function tryRestoreRecord( + tree: LayoutNode, + record: CollapseRecord +): LayoutNode | null { + const parentSplit = findNode(tree, record.parentSplitId); + if (!parentSplit || parentSplit.type !== 'split') return null; + + const targetGroup = findNode(tree, record.targetGroupId); + if (!targetGroup || targetGroup.type !== 'tabgroup') return null; + + const panelCount = Math.min(record.panels.length, targetGroup.panels.length - 1); + if (panelCount <= 0) return null; + + let panelsToRestore: string[]; + let remainingPanels: string[]; + + if (record.insertPosition === 'before') { + panelsToRestore = targetGroup.panels.slice(0, panelCount); + remainingPanels = targetGroup.panels.slice(panelCount); + } else { + panelsToRestore = targetGroup.panels.slice(-panelCount); + remainingPanels = targetGroup.panels.slice(0, -panelCount); + } + + if (remainingPanels.length === 0) return null; + + const activePanel = targetGroup.panels[targetGroup.activeIndex]; + let newTargetActive: number; + if (panelsToRestore.includes(activePanel)) { + newTargetActive = 0; + } else { + newTargetActive = remainingPanels.indexOf(activePanel); + if (newTargetActive < 0) newTargetActive = 0; + } + + let newTree = replaceNode(tree, targetGroup.id, { + ...targetGroup, + panels: remainingPanels, + activeIndex: newTargetActive, + }); + + const restoredGroup: TabGroupNode = { + type: 'tabgroup', + id: record.removedGroupId, + panels: panelsToRestore, + activeIndex: Math.min(record.originalActiveIndex, panelsToRestore.length - 1), + }; + + const currentParent = findNode(newTree, record.parentSplitId) as SplitNode; + if (!currentParent || currentParent.type !== 'split') return null; + + const newChildren = [...currentParent.children]; + const newSizes = [...currentParent.sizes]; + + const scaleFactor = 1 - record.originalSize; + for (let i = 0; i < newSizes.length; i++) { + newSizes[i] *= scaleFactor; + } + + const insertIdx = Math.min(record.originalIndex, newChildren.length); + newChildren.splice(insertIdx, 0, restoredGroup); + newSizes.splice(insertIdx, 0, record.originalSize); + + newTree = replaceNode(newTree, record.parentSplitId, { + ...currentParent, + children: newChildren, + sizes: newSizes, + }); + + return newTree; +} + +// ─── CSS Injection ─────────────────────────────────────────────────────────── + +let ideStyleEl: HTMLStyleElement | null = null; + +export function injectIDEStyles(): void { + if (ideStyleEl) return; + ideStyleEl = document.createElement('style'); + ideStyleEl.textContent = ` + .ide-layout { + width: 100%; + min-height: 100%; + position: relative; + } + + /* Split containers */ + .ide-split { + display: flex; + width: 100%; + min-height: 100%; + } + .ide-split--horizontal { flex-direction: row; } + .ide-split--vertical { flex-direction: column; } + + .ide-split__child { + min-width: 0; + min-height: 0; + } + + /* Resize handles */ + .ide-resize-handle { + flex: 0 0 ${HANDLE_SIZE}px; + position: relative; + z-index: 10; + background: rgba(255,255,255,0.06); + transition: background-color 0.15s ease; + } + .ide-resize-handle--horizontal { + cursor: col-resize; + width: ${HANDLE_SIZE}px; + } + .ide-resize-handle--horizontal::before { + content: ''; + position: absolute; + top: 0; bottom: 0; + left: -4px; right: -4px; + } + .ide-resize-handle--vertical { + cursor: row-resize; + height: ${HANDLE_SIZE}px; + } + .ide-resize-handle--vertical::before { + content: ''; + position: absolute; + left: 0; right: 0; + top: -4px; bottom: -4px; + } + .ide-resize-handle:hover, + .ide-resize-handle--active { + background: ${PHOSPHOR}; + } + + /* Tab groups */ + .ide-tabgroup { + display: flex; + flex-direction: column; + width: 100%; + min-height: 100%; + position: relative; + } + /* Sticky tabgroup: viewport-pinned. + min-height reset so the element is shorter than its stretched + flex parent — sticky needs room to travel. No overflow here + (overflow on the element itself would create a scroll container + and kill position:sticky relative to the page). */ + .ide-tabgroup--sticky { + position: sticky; + top: 0; + min-height: 0; + height: 100%; + max-height: 100vh; + } + /* Scroll lives on the panel content inside a sticky tabgroup */ + .ide-tabgroup--sticky > .ide-panel-content { + overflow-y: auto; + min-height: 0; + } + .ide-tabgroup--sticky > .ide-panel-content::-webkit-scrollbar { width: 2px; } + .ide-tabgroup--sticky > .ide-panel-content::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); } + + .ide-tabbar { + display: flex; + flex: 0 0 auto; + background: ${CRT_SCREEN_BG}; + border-bottom: 1px solid rgba(255,255,255,0.08); + overflow-x: auto; + overflow-y: hidden; + min-height: 32px; + position: sticky; + top: 0; + z-index: 5; + } + .ide-tabbar::-webkit-scrollbar { height: 2px; } + .ide-tabbar::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); } + + .ide-tab { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 12px; + border-bottom: 2px solid transparent; + cursor: pointer; + user-select: none; + white-space: nowrap; + font-size: 12px; + line-height: 1; + color: rgba(255,255,255,0.4); + background: ${CRT_SCREEN_BG}; + border-right: 1px solid rgba(255,255,255,0.06); + transition: background-color 0.1s ease, color 0.1s ease; + font-family: 'Courier New', Courier, monospace; + } + .ide-tab:hover { + background: rgba(255,255,255,0.04); + color: rgba(255,255,255,0.7); + } + .ide-tab--active { + color: ${PHOSPHOR}; + background: rgba(255,255,255,0.04); + border-bottom-color: ${PHOSPHOR}; + } + .ide-tab--dragging { opacity: 0.5; } + + .ide-tab__icon { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + } + .ide-tab__icon svg { + width: 14px; + height: 14px; + } + .ide-tab__title { flex: 1 1 auto; } + .ide-tab__close { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border-radius: 3px; + opacity: 0; + transition: opacity 0.1s ease, background-color 0.1s ease; + cursor: pointer; + font-size: 14px; + line-height: 1; + } + .ide-tab__close:hover { background: rgba(255,255,255,0.15); } + .ide-tab:hover .ide-tab__close, + .ide-tab--active .ide-tab__close { opacity: 1; } + + /* Panel content — no overflow constraints so content flows into page scroll */ + .ide-panel-content { + flex: 1 1 auto; + background: transparent; + } + /* When layout has splits: root constrained to viewport, everything scrolls internally. + Page scroll only used when a single tabgroup is the root (one file open). */ + .ide-split--contained { height: 100vh; min-height: 0; } + .ide-split--contained .ide-split { height: 100%; min-height: 0; } + .ide-split--contained .ide-tabgroup { height: 100%; min-height: 0; } + .ide-split--contained .ide-panel-content { overflow-y: auto; min-height: 0; } + .ide-split--contained .ide-panel-content::-webkit-scrollbar { width: 2px; } + .ide-split--contained .ide-panel-content::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); } + /* Vertical split children: explicit vh heights for proportional sizing */ + .ide-split__child--v-constrained { overflow: clip; } + + /* Tab insertion indicator */ + .ide-tab-insert-indicator { + flex: 0 0 2px; + align-self: stretch; + background: ${PHOSPHOR}; + } + + /* Drop indicator overlay */ + .ide-drop-indicator { + position: absolute; + pointer-events: none; + z-index: 100; + background: rgba(255,255,255,0.06); + border: 2px solid rgba(255,255,255,0.25); + transition: all 0.1s ease; + } + `; + document.head.appendChild(ideStyleEl); +} + +// ─── DOM Rendering Engine ──────────────────────────────────────────────────── + +interface LayoutState { + layout: LayoutNode; + panelRegistry: Map; + dragState: DragState | null; + dropZone: DropZone | null; + panelCleanups: Map void>; + panelElements: Map; // panel content containers keyed by panelId + nodeElements: Map; // layout node containers keyed by nodeId +} + +function createDropIndicator(type: DropZoneType): HTMLElement { + const el = document.createElement('div'); + el.className = 'ide-drop-indicator'; + el.dataset.dropType = type; + // Position will be set by positionDropIndicators() after mount + el.style.display = 'none'; + return el; +} + +/** Reposition drop indicators to cover only the visible portion of their container */ +function positionDropIndicators(root: HTMLElement): void { + root.querySelectorAll('.ide-drop-indicator').forEach(indicator => { + const el = indicator as HTMLElement; + const type = el.dataset.dropType as DropZoneType; + const parent = el.parentElement; + if (!parent) return; + + const rect = parent.getBoundingClientRect(); + const vpH = window.innerHeight; + // Visible portion of the container within the viewport + const visTop = Math.max(0, rect.top); + const visBot = Math.min(vpH, rect.bottom); + const visH = Math.max(0, visBot - visTop); + // Convert to parent-relative coordinates + const offsetTop = visTop - rect.top; + + el.style.display = ''; + switch (type) { + case 'tab': + Object.assign(el.style, { top: `${offsetTop}px`, left: '0', right: '0', height: `${visH}px` }); + break; + case 'left': + Object.assign(el.style, { top: `${offsetTop}px`, left: '0', height: `${visH}px`, width: '50%' }); + break; + case 'right': + Object.assign(el.style, { top: `${offsetTop}px`, right: '0', height: `${visH}px`, width: '50%' }); + break; + case 'top': + Object.assign(el.style, { top: `${offsetTop}px`, left: '0', right: '0', height: `${visH / 2}px` }); + break; + case 'bottom': + Object.assign(el.style, { top: `${offsetTop + visH / 2}px`, left: '0', right: '0', height: `${visH / 2}px` }); + break; + } + }); +} + +function renderTabBar( + node: TabGroupNode, + state: LayoutState, + tabBarEl: HTMLElement, + ctx: LayoutContext +): void { + tabBarEl.innerHTML = ''; + + const showInsert = + state.dropZone && + state.dropZone.targetId === node.id && + state.dropZone.type === 'tab' && + state.dropZone.insertIndex !== undefined; + const insertAt = state.dropZone?.insertIndex ?? -1; + + let nonDragIdx = 0; + + for (let i = 0; i < node.panels.length; i++) { + const panelId = node.panels[i]; + const isDragging = state.dragState?.panelId === panelId; + + if (!isDragging) { + if (showInsert && insertAt === nonDragIdx) { + const indicator = document.createElement('div'); + indicator.className = 'ide-tab-insert-indicator'; + tabBarEl.appendChild(indicator); + } + nonDragIdx++; + } + + const panel = state.panelRegistry.get(panelId); + if (!panel) continue; + + const tab = document.createElement('div'); + tab.className = 'ide-tab'; + if (i === node.activeIndex) tab.className += ' ide-tab--active'; + if (isDragging) tab.className += ' ide-tab--dragging'; + tab.draggable = true; + tab.dataset.panelId = panelId; + tab.dataset.groupId = node.id; + + if (panel.icon) { + const iconSpan = document.createElement('span'); + iconSpan.className = 'ide-tab__icon'; + iconSpan.innerHTML = panel.icon; + tab.appendChild(iconSpan); + } + + const titleSpan = document.createElement('span'); + titleSpan.className = 'ide-tab__title'; + titleSpan.textContent = panel.title; + tab.appendChild(titleSpan); + + if (panel.closable) { + const closeSpan = document.createElement('span'); + closeSpan.className = 'ide-tab__close'; + closeSpan.textContent = '\u00d7'; + closeSpan.addEventListener('click', (e) => { + e.stopPropagation(); + ctx.closePanel(node.id, panelId); + }); + tab.appendChild(closeSpan); + } + + // Tab click + tab.addEventListener('click', () => { + ctx.setActiveTab(node.id, i); + }); + + // Drag start + tab.addEventListener('dragstart', (e) => { + e.dataTransfer!.setData('text/plain', panelId); + e.dataTransfer!.effectAllowed = 'move'; + setTimeout(() => { + ctx.setDragState({ panelId, sourceGroupId: node.id }); + }, 0); + }); + + // Drag end + tab.addEventListener('dragend', () => { + ctx.setDragState(null); + ctx.setDropZone(null); + }); + + tabBarEl.appendChild(tab); + } + + // Indicator at end + if (showInsert && insertAt === nonDragIdx) { + const indicator = document.createElement('div'); + indicator.className = 'ide-tab-insert-indicator'; + tabBarEl.appendChild(indicator); + } +} + +function computeTabInsertIndex(tabBarEl: HTMLElement, clientX: number, draggedPanelId: string | null): number { + const tabs = tabBarEl.querySelectorAll('.ide-tab:not(.ide-tab--dragging)'); + for (let i = 0; i < tabs.length; i++) { + const rect = tabs[i].getBoundingClientRect(); + const midX = rect.left + rect.width / 2; + if (clientX < midX) return i; + } + return tabs.length; +} + +function detectDropZone( + containerEl: HTMLElement, + tabBarEl: HTMLElement, + e: DragEvent, + draggedPanelId: string | null +): { type: DropZoneType; insertIndex?: number } | null { + const tabBarRect = tabBarEl.getBoundingClientRect(); + if (e.clientY < tabBarRect.bottom) { + return { type: 'tab', insertIndex: computeTabInsertIndex(tabBarEl, e.clientX, draggedPanelId) }; + } + + const rect = containerEl.getBoundingClientRect(); + const x = (e.clientX - rect.left) / rect.width; + // Use visible portion of container for y-axis detection (tall panels) + const vpH = window.innerHeight; + const visTop = Math.max(rect.top, 0); + const visBot = Math.min(rect.bottom, vpH); + const visH = visBot - visTop; + const y = visH > 0 ? (e.clientY - visTop) / visH : 0.5; + const threshold = 0.25; + + if (x < threshold) return { type: 'left' }; + if (x > 1 - threshold) return { type: 'right' }; + if (y < threshold) return { type: 'top' }; + if (y > 1 - threshold) return { type: 'bottom' }; + return { type: 'tab', insertIndex: computeTabInsertIndex(tabBarEl, e.clientX, draggedPanelId) }; +} + +function renderTabGroupNode( + node: TabGroupNode, + state: LayoutState, + ctx: LayoutContext +): HTMLElement { + const container = document.createElement('div'); + // Only sticky when the *active* panel is sticky — when a non-sticky panel + // (e.g. a file viewer) is active in the same group (after responsive collapse), + // let its content flow into the page scroll. + const activePanel = node.panels[node.activeIndex]; + const isSticky = !!activePanel && !!(state.panelRegistry.get(activePanel)?.sticky); + container.className = isSticky ? 'ide-tabgroup ide-tabgroup--sticky' : 'ide-tabgroup'; + container.dataset.groupId = node.id; + + const tabBar = document.createElement('div'); + tabBar.className = 'ide-tabbar'; + container.appendChild(tabBar); + + renderTabBar(node, state, tabBar, ctx); + + // Panel content areas + for (let i = 0; i < node.panels.length; i++) { + const panelId = node.panels[i]; + const panel = state.panelRegistry.get(panelId); + if (!panel) continue; + + let contentEl = state.panelElements.get(panelId); + if (!contentEl) { + contentEl = document.createElement('div'); + contentEl.className = 'ide-panel-content'; + contentEl.dataset.panelId = panelId; + state.panelElements.set(panelId, contentEl); + // First mount — call render + const cleanup = panel.render(contentEl); + if (cleanup) state.panelCleanups.set(panelId, cleanup); + } + + contentEl.style.display = i === node.activeIndex ? '' : 'none'; + container.appendChild(contentEl); + } + + // Drop indicator + const isDropTarget = state.dropZone && state.dropZone.targetId === node.id; + if (isDropTarget && state.dropZone!.type !== 'tab') { + container.appendChild(createDropIndicator(state.dropZone!.type)); + } + + // Drag-and-drop event handlers on the container + container.addEventListener('dragover', (e) => { + if (!state.dragState) return; + if (state.dragState.sourceGroupId === node.id && node.panels.length === 1) return; + + e.preventDefault(); + e.dataTransfer!.dropEffect = 'move'; + + const zone = detectDropZone(container, tabBar, e, state.dragState.panelId); + if (zone) { + ctx.setDropZone({ + targetId: node.id, + type: zone.type, + insertIndex: zone.insertIndex, + }); + } + }); + + container.addEventListener('dragleave', (e) => { + if (container && !container.contains(e.relatedTarget as Node)) { + ctx.setDropZone(null); + } + }); + + container.addEventListener('drop', (e) => { + e.preventDefault(); + if (!state.dragState || !state.dropZone || state.dropZone.targetId !== node.id) { + ctx.setDropZone(null); + return; + } + + const { panelId, sourceGroupId } = state.dragState; + + if (state.dropZone.type === 'tab') { + ctx.movePanelToTabGroup(panelId, sourceGroupId, node.id, state.dropZone.insertIndex); + } else { + ctx.splitAndPlace(panelId, sourceGroupId, node.id, state.dropZone.type as 'left' | 'right' | 'top' | 'bottom'); + } + + ctx.setDropZone(null); + ctx.setDragState(null); + }); + + state.nodeElements.set(node.id, container); + return container; +} + +function renderSplitNode( + node: SplitNode, + state: LayoutState, + ctx: LayoutContext +): HTMLElement { + const container = document.createElement('div'); + container.className = `ide-split ide-split--${node.direction}`; + + for (let i = 0; i < node.children.length; i++) { + if (i > 0) { + const handle = document.createElement('div'); + handle.className = `ide-resize-handle ide-resize-handle--${node.direction}`; + setupResizeHandle(handle, node, i - 1, state, ctx); + container.appendChild(handle); + } + + const childEl = document.createElement('div'); + childEl.className = 'ide-split__child'; + const size = node.sizes[i]; + const handleCount = node.children.length - 1; + if (node.direction === 'vertical') { + // All vertical children get explicit vh heights (proportional to viewport) + const totalHandlePx = handleCount * HANDLE_SIZE; + childEl.classList.add('ide-split__child--v-constrained'); + childEl.style.height = `calc(${(size * 100).toFixed(4)}vh - ${(size * totalHandlePx).toFixed(2)}px)`; + childEl.style.flex = '0 0 auto'; + } else { + const sizeExpr = `calc(${size * 100}% - ${(handleCount * HANDLE_SIZE) / node.children.length}px)`; + childEl.style.flex = `0 0 ${sizeExpr}`; + } + + const childContent = renderLayoutNode(node.children[i], state, ctx); + childEl.appendChild(childContent); + container.appendChild(childEl); + } + + state.nodeElements.set(node.id, container); + return container; +} + +function renderLayoutNode( + node: LayoutNode, + state: LayoutState, + ctx: LayoutContext +): HTMLElement { + if (node.type === 'split') { + return renderSplitNode(node, state, ctx); + } else { + return renderTabGroupNode(node, state, ctx); + } +} + +// ─── Resize Handle Logic ───────────────────────────────────────────────────── + +function setupResizeHandle( + handle: HTMLElement, + node: SplitNode, + index: number, + state: LayoutState, + ctx: LayoutContext +): void { + handle.addEventListener('mousedown', (e) => { + e.preventDefault(); + handle.classList.add('ide-resize-handle--active'); + + const direction = node.direction; + const startPos = direction === 'horizontal' ? e.clientX : e.clientY; + + const containerEl = handle.parentElement; + if (!containerEl) return; + const containerRect = containerEl.getBoundingClientRect(); + // For vertical splits, use viewport height as reference (children are vh-based) + const containerSize = direction === 'horizontal' ? containerRect.width : window.innerHeight; + + // Snapshot sizes at mousedown + const currentNode = findNode(state.layout, node.id) as SplitNode | null; + if (!currentNode || currentNode.type !== 'split') return; + const startSizes = [...currentNode.sizes]; + + const onMouseMove = (moveEvent: MouseEvent) => { + const currentPos = + direction === 'horizontal' ? moveEvent.clientX : moveEvent.clientY; + const deltaPx = currentPos - startPos; + const numHandles = startSizes.length - 1; + const availableSize = containerSize - numHandles * HANDLE_SIZE; + const deltaFraction = deltaPx / availableSize; + + const newSizes = [...startSizes]; + let newLeft = newSizes[index] + deltaFraction; + let newRight = newSizes[index + 1] - deltaFraction; + + const minSize = ctx.minPanelSize; + if (newLeft < minSize) { + const diff = minSize - newLeft; + newLeft = minSize; + newRight -= diff; + } + if (newRight < minSize) { + const diff = minSize - newRight; + newRight = minSize; + newLeft -= diff; + } + + newSizes[index] = newLeft; + newSizes[index + 1] = newRight; + + ctx.updateSizes(node.id, newSizes); + }; + + const onMouseUp = () => { + handle.classList.remove('ide-resize-handle--active'); + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + ctx.notifyLayoutChange(); + }; + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + document.body.style.cursor = + direction === 'horizontal' ? 'col-resize' : 'row-resize'; + document.body.style.userSelect = 'none'; + }); +} + +// ─── Layout Context (internal callbacks) ───────────────────────────────────── + +interface LayoutContext { + setActiveTab: (groupId: string, index: number) => void; + setDragState: (state: DragState | null) => void; + setDropZone: (zone: DropZone | null) => void; + movePanelToTabGroup: (panelId: string, sourceGroupId: string, targetGroupId: string, insertIndex?: number) => void; + splitAndPlace: (panelId: string, sourceGroupId: string, targetGroupId: string, edge: 'left' | 'right' | 'top' | 'bottom') => void; + closePanel: (groupId: string, panelId: string) => void; + updateSizes: (splitId: string, sizes: number[]) => void; + notifyLayoutChange: () => void; + minPanelSize: number; +} + +// ─── Public API ────────────────────────────────────────────────────────────── + +export interface IDELayoutAPI { + unmount(): void; + getLayout(): LayoutNode; + openPanel(panel: PanelDefinition): void; + updatePanel(panelId: string, renderFn: (container: HTMLElement) => (() => void) | void): void; +} + +export function createIDELayout(container: HTMLElement, options: { + panels: PanelDefinition[]; + initialLayout: LayoutNode; + minPanelSize?: number; + onNavigate?: (path: string) => void; + onActiveTabChange?: (panelId: string) => void; + onLayoutChange?: (layout: LayoutNode) => void; +}): IDELayoutAPI { + injectIDEStyles(); + + const minPanelSize = options.minPanelSize ?? 0.05; + + // State + const state: LayoutState = { + layout: options.initialLayout, + panelRegistry: new Map(), + dragState: null, + dropZone: null, + panelCleanups: new Map void>(), + panelElements: new Map(), + nodeElements: new Map(), + }; + + for (const panel of options.panels) { + state.panelRegistry.set(panel.id, panel); + } + + const collapseStack: CollapseRecord[] = []; + let collapseGrace = false; + let destroyed = false; + + // ── Re-render ── + + function rerender(): void { + if (destroyed) return; + // Clean up old node elements (panel elements are preserved) + state.nodeElements.clear(); + container.innerHTML = ''; + const root = renderLayoutNode(state.layout, state, ctx); + // When layout has splits, contain to viewport (internal scroll per panel). + // Single tabgroup = page scroll (only file open). + if (state.layout.type === 'split') { + root.classList.add('ide-split--contained'); + } + container.appendChild(root); + // Position drop indicators now that elements are in the DOM + requestAnimationFrame(() => positionDropIndicators(container)); + } + + /** Lightweight visual update during drag — avoids destroying the DOM tree. */ + function updateDragVisuals(): void { + if (destroyed) return; + + // 1. Update dragging class on tabs + container.querySelectorAll('.ide-tab').forEach(tab => { + const pid = tab.dataset.panelId; + if (state.dragState && pid === state.dragState.panelId) { + tab.classList.add('ide-tab--dragging'); + } else { + tab.classList.remove('ide-tab--dragging'); + } + }); + + // 2. Remove old insert indicators and drop overlays + container.querySelectorAll('.ide-tab-insert-indicator').forEach(el => el.remove()); + container.querySelectorAll('.ide-drop-indicator').forEach(el => el.remove()); + + if (!state.dropZone) return; + + // 3. Show tab insert indicator + if (state.dropZone.type === 'tab' && state.dropZone.insertIndex !== undefined) { + const groupEl = container.querySelector( + `.ide-tabgroup[data-group-id="${state.dropZone.targetId}"]` + ); + const tabBar = groupEl?.querySelector('.ide-tabbar'); + if (tabBar) { + const tabs = tabBar.querySelectorAll('.ide-tab:not(.ide-tab--dragging)'); + const idx = state.dropZone.insertIndex; + const indicator = document.createElement('div'); + indicator.className = 'ide-tab-insert-indicator'; + if (idx < tabs.length) { + tabBar.insertBefore(indicator, tabs[idx]); + } else { + tabBar.appendChild(indicator); + } + } + } else { + // 4. Show edge drop overlay + const groupEl = container.querySelector( + `.ide-tabgroup[data-group-id="${state.dropZone.targetId}"]` + ); + if (groupEl) { + groupEl.appendChild(createDropIndicator(state.dropZone.type)); + requestAnimationFrame(() => positionDropIndicators(container)); + } + } + } + + function notifyLayoutChange(): void { + if (options.onLayoutChange) options.onLayoutChange(state.layout); + } + + // ── Context callbacks ── + + const ctx: LayoutContext = { + minPanelSize, + + setActiveTab(groupId: string, index: number) { + const node = findNode(state.layout, groupId); + if (!node || node.type !== 'tabgroup') return; + state.layout = replaceNode(state.layout, groupId, { + ...node, + activeIndex: index, + }); + if (options.onActiveTabChange && node.panels[index]) { + options.onActiveTabChange(node.panels[index]); + } + rerender(); + }, + + setDragState(ds: DragState | null) { + state.dragState = ds; + updateDragVisuals(); + }, + + setDropZone(dz: DropZone | null) { + const prev = state.dropZone; + // Skip update if nothing changed + if (prev === dz) return; + if (prev && dz && + prev.targetId === dz.targetId && + prev.type === dz.type && + prev.insertIndex === dz.insertIndex) return; + state.dropZone = dz; + updateDragVisuals(); + }, + + movePanelToTabGroup( + panelId: string, + sourceGroupId: string, + targetGroupId: string, + insertIndex?: number + ) { + if (sourceGroupId === targetGroupId) { + // Same-group reorder + const group = findNode(state.layout, sourceGroupId); + if (!group || group.type !== 'tabgroup') return; + const newPanels = group.panels.filter((p) => p !== panelId); + const idx = insertIndex !== undefined + ? Math.min(insertIndex, newPanels.length) + : newPanels.length; + newPanels.splice(idx, 0, panelId); + state.layout = replaceNode(state.layout, sourceGroupId, { + ...group, + panels: newPanels, + activeIndex: idx, + }); + } else { + // Cross-group move + collapseStack.length = 0; + let tree = removePanelFromNode(state.layout, sourceGroupId, panelId); + const target = findNode(tree, targetGroupId); + if (!target || target.type !== 'tabgroup') return; + const newPanels = [...target.panels]; + const idx = insertIndex !== undefined + ? Math.min(insertIndex, newPanels.length) + : newPanels.length; + newPanels.splice(idx, 0, panelId); + tree = replaceNode(tree, targetGroupId, { + ...target, + panels: newPanels, + activeIndex: idx, + }); + state.layout = normalizeTree(tree) || tree; + } + notifyLayoutChange(); + rerender(); + }, + + splitAndPlace( + panelId: string, + sourceGroupId: string, + targetGroupId: string, + edge: 'left' | 'right' | 'top' | 'bottom' + ) { + collapseStack.length = 0; + let tree = removePanelFromNode(state.layout, sourceGroupId, panelId); + + const newGroup: TabGroupNode = { + type: 'tabgroup', + id: generateId(), + panels: [panelId], + activeIndex: 0, + }; + + const target = findNode(tree, targetGroupId); + if (!target) return; + + const direction: 'horizontal' | 'vertical' = + edge === 'left' || edge === 'right' ? 'horizontal' : 'vertical'; + const newFirst = edge === 'left' || edge === 'top'; + + const parentInfo = findParent(tree, targetGroupId); + if (parentInfo && parentInfo.parent.direction === direction) { + const { parent, index: targetIndex } = parentInfo; + const newChildren = [...parent.children]; + const newSizes = [...parent.sizes]; + const targetSize = newSizes[targetIndex]; + const insertIdx = newFirst ? targetIndex : targetIndex + 1; + newChildren.splice(insertIdx, 0, newGroup); + newSizes[targetIndex] = targetSize / 2; + newSizes.splice(insertIdx, 0, targetSize / 2); + tree = replaceNode(tree, parent.id, { + ...parent, + children: newChildren, + sizes: newSizes, + }); + } else { + const newSplit: SplitNode = { + type: 'split', + id: generateId(), + direction, + children: newFirst ? [newGroup, target] : [target, newGroup], + sizes: [0.5, 0.5], + }; + tree = replaceNode(tree, targetGroupId, newSplit); + } + + state.layout = normalizeTree(tree) || tree; + notifyLayoutChange(); + rerender(); + }, + + closePanel(groupId: string, panelId: string) { + collapseStack.length = 0; + + // Clean up panel + const cleanup = state.panelCleanups.get(panelId); + if (cleanup) { cleanup(); state.panelCleanups.delete(panelId); } + state.panelElements.delete(panelId); + + const tree = removePanelFromNode(state.layout, groupId, panelId); + state.layout = normalizeTree(tree) || tree; + notifyLayoutChange(); + rerender(); + }, + + updateSizes(splitId: string, sizes: number[]) { + const node = findNode(state.layout, splitId); + if (!node || node.type !== 'split') return; + state.layout = replaceNode(state.layout, splitId, { + ...node, + sizes, + }); + // Update DOM directly for sizes (avoid full rerender during drag) + const splitEl = state.nodeElements.get(splitId); + if (splitEl) { + const children = splitEl.querySelectorAll(':scope > .ide-split__child'); + const handleCount = sizes.length - 1; + const isVertical = node.direction === 'vertical'; + children.forEach((child, i) => { + if (i < sizes.length) { + const el = child as HTMLElement; + if (isVertical) { + const totalHandlePx = handleCount * HANDLE_SIZE; + el.classList.add('ide-split__child--v-constrained'); + el.style.height = `calc(${(sizes[i] * 100).toFixed(4)}vh - ${(sizes[i] * totalHandlePx).toFixed(2)}px)`; + el.style.flex = '0 0 auto'; + } else { + const sizeExpr = `calc(${sizes[i] * 100}% - ${(handleCount * HANDLE_SIZE) / sizes.length}px)`; + el.style.flex = `0 0 ${sizeExpr}`; + } + } + }); + } + }, + + notifyLayoutChange, + }; + + // ── Responsive Collapse / Restore (ResizeObserver) ── + + let rafId: number; + let prevWidth = 0; + let prevHeight = 0; + + function handleResize(width: number, height: number): void { + let tree = state.layout; + let changed = false; + + // Phase 1: Restore + let didRestore = false; + let didChange = true; + while (didChange && collapseStack.length > 0) { + didChange = false; + const record = collapseStack[collapseStack.length - 1]; + const dim = record.direction === 'horizontal' ? width : height; + if (dim >= record.collapsedAtDim + RESTORE_HYSTERESIS) { + const result = tryRestoreRecord(tree, record); + if (result) { + tree = result; + collapseStack.pop(); + didChange = true; + didRestore = true; + changed = true; + } else { + collapseStack.pop(); + didChange = true; + } + } + } + + // Phase 2: Collapse + const skipCollapse = didRestore || collapseGrace; + collapseGrace = didRestore; + if (!skipCollapse) { + let collapsed = true; + while (collapsed) { + collapsed = false; + const target = findSmallestBelowThreshold(tree, width, height); + if (target && target.parentSplit.children.length > 1) { + const result = performSingleCollapse(tree, target, width, height); + if (result) { + tree = result.tree; + if (!collapseStack.some(r => r.removedGroupId === result.record.removedGroupId)) { + collapseStack.push(result.record); + } + collapsed = true; + changed = true; + } + } + } + } + + if (changed) { + state.layout = tree; + notifyLayoutChange(); + rerender(); + } + } + + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (!entry) return; + const { width, height } = entry.contentRect; + if (Math.abs(width - prevWidth) < 1 && Math.abs(height - prevHeight) < 1) return; + prevWidth = width; + prevHeight = height; + cancelAnimationFrame(rafId); + rafId = requestAnimationFrame(() => { + handleResize(width, height); + }); + }); + + observer.observe(container); + + // ── Initial render ── + rerender(); + + // ── API ── + + return { + unmount() { + destroyed = true; + observer.disconnect(); + cancelAnimationFrame(rafId); + // Clean up all panels + for (const [, cleanup] of state.panelCleanups) cleanup(); + state.panelCleanups.clear(); + state.panelElements.clear(); + state.nodeElements.clear(); + container.innerHTML = ''; + }, + + getLayout() { + return state.layout; + }, + + openPanel(panel: PanelDefinition) { + state.panelRegistry.set(panel.id, panel); + + // Check if already in layout + const existing = findPanelInLayout(state.layout, panel.id); + if (existing) { + // Just activate it + ctx.setActiveTab(existing.groupId, existing.index); + return; + } + + // Find widest tab group via DOM measurement + const tabGroupEls = container.querySelectorAll('.ide-tabgroup[data-group-id]'); + let widestId = ''; + let widestWidth = 0; + tabGroupEls.forEach(el => { + const groupId = el.getAttribute('data-group-id'); + if (!groupId) return; + const width = el.getBoundingClientRect().width; + if (width > widestWidth) { + widestWidth = width; + widestId = groupId; + } + }); + + if (!widestId) return; + + const group = findNode(state.layout, widestId); + if (!group || group.type !== 'tabgroup') return; + + // Insert left of the currently active tab + const newPanels = [...group.panels]; + const insertIdx = group.activeIndex; + newPanels.splice(insertIdx, 0, panel.id); + + state.layout = replaceNode(state.layout, widestId, { + ...group, + panels: newPanels, + activeIndex: insertIdx, + }); + if (options.onActiveTabChange) options.onActiveTabChange(panel.id); + notifyLayoutChange(); + rerender(); + }, + + updatePanel(panelId: string, renderFn: (container: HTMLElement) => (() => void) | void) { + // Clean up old + const oldCleanup = state.panelCleanups.get(panelId); + if (oldCleanup) { oldCleanup(); state.panelCleanups.delete(panelId); } + + const el = state.panelElements.get(panelId); + if (el) { + el.innerHTML = ''; + const cleanup = renderFn(el); + if (cleanup) state.panelCleanups.set(panelId, cleanup); + } + }, + }; +} diff --git a/@ether/.html/UI/Markdown.ts b/@ether/.html/UI/Markdown.ts new file mode 100644 index 00000000..6174d44d --- /dev/null +++ b/@ether/.html/UI/Markdown.ts @@ -0,0 +1,247 @@ +// ============================================================ +// Markdown.ts — Zero-dependency markdown parser → HTML string +// ============================================================ + +function escapeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +// ---- Inline Parsing ---- + +function parseInline(text: string): string { + let out = ''; + let i = 0; + const len = text.length; + + while (i < len) { + // Images: ![alt](url) + if (text[i] === '!' && text[i + 1] === '[') { + const altEnd = text.indexOf(']', i + 2); + if (altEnd !== -1 && text[altEnd + 1] === '(') { + const urlEnd = text.indexOf(')', altEnd + 2); + if (urlEnd !== -1) { + const alt = escapeHtml(text.slice(i + 2, altEnd)); + const url = escapeHtml(text.slice(altEnd + 2, urlEnd)); + out += `${alt}`; + i = urlEnd + 1; + continue; + } + } + } + + // Links: [text](url) + if (text[i] === '[') { + const textEnd = text.indexOf(']', i + 1); + if (textEnd !== -1 && text[textEnd + 1] === '(') { + const urlEnd = text.indexOf(')', textEnd + 2); + if (urlEnd !== -1) { + const linkText = parseInline(text.slice(i + 1, textEnd)); + const url = escapeHtml(text.slice(textEnd + 2, urlEnd)); + out += `${linkText}`; + i = urlEnd + 1; + continue; + } + } + } + + // Code spans: `code` + if (text[i] === '`') { + const end = text.indexOf('`', i + 1); + if (end !== -1) { + out += `${escapeHtml(text.slice(i + 1, end))}`; + i = end + 1; + continue; + } + } + + // Bold + italic: ***text*** or ___text___ + if ((text[i] === '*' || text[i] === '_') && text[i + 1] === text[i] && text[i + 2] === text[i]) { + const ch = text[i]; + const close = text.indexOf(ch + ch + ch, i + 3); + if (close !== -1) { + out += `${parseInline(text.slice(i + 3, close))}`; + i = close + 3; + continue; + } + } + + // Bold: **text** or __text__ + if ((text[i] === '*' || text[i] === '_') && text[i + 1] === text[i]) { + const ch = text[i]; + const close = text.indexOf(ch + ch, i + 2); + if (close !== -1) { + out += `${parseInline(text.slice(i + 2, close))}`; + i = close + 2; + continue; + } + } + + // Strikethrough: ~~text~~ + if (text[i] === '~' && text[i + 1] === '~') { + const close = text.indexOf('~~', i + 2); + if (close !== -1) { + out += `${parseInline(text.slice(i + 2, close))}`; + i = close + 2; + continue; + } + } + + // Italic: *text* or _text_ + if (text[i] === '*' || text[i] === '_') { + const ch = text[i]; + const close = text.indexOf(ch, i + 1); + if (close !== -1 && close > i + 1) { + out += `${parseInline(text.slice(i + 1, close))}`; + i = close + 1; + continue; + } + } + + out += escapeHtml(text[i]); + i++; + } + + return out; +} + +// ---- Block Parsing ---- + +export function renderMarkdown(source: string): string { + const lines = source.split('\n'); + const html: string[] = []; + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + + // Fenced code blocks + if (line.startsWith('```')) { + const lang = line.slice(3).trim(); + const codeLines: string[] = []; + i++; + while (i < lines.length && !lines[i].startsWith('```')) { + codeLines.push(lines[i]); + i++; + } + i++; // skip closing ``` + const langAttr = lang ? ` class="language-${escapeHtml(lang)}"` : ''; + html.push(`
${escapeHtml(codeLines.join('\n'))}
`); + continue; + } + + // Headings + const headingMatch = line.match(/^(#{1,6})\s+(.*)$/); + if (headingMatch) { + const level = headingMatch[1].length; + html.push(`${parseInline(headingMatch[2])}`); + i++; + continue; + } + + // Horizontal rule + if (/^(\*{3,}|-{3,}|_{3,})\s*$/.test(line)) { + html.push('
'); + i++; + continue; + } + + // Table + if (line.includes('|') && i + 1 < lines.length && /^\|?\s*[-:]+[-| :]*$/.test(lines[i + 1])) { + const parseRow = (row: string): string[] => + row.replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => c.trim()); + + const headers = parseRow(line); + const alignLine = parseRow(lines[i + 1]); + const aligns: string[] = alignLine.map(a => { + if (a.startsWith(':') && a.endsWith(':')) return 'center'; + if (a.endsWith(':')) return 'right'; + return 'left'; + }); + + let table = ''; + headers.forEach((h, idx) => { + table += ``; + }); + table += ''; + + i += 2; + while (i < lines.length && lines[i].includes('|')) { + const cells = parseRow(lines[i]); + table += ''; + cells.forEach((c, idx) => { + table += ``; + }); + table += ''; + i++; + } + table += '
${parseInline(h)}
${parseInline(c)}
'; + html.push(table); + continue; + } + + // Blockquote + if (line.startsWith('>')) { + const quoteLines: string[] = []; + while (i < lines.length && lines[i].startsWith('>')) { + quoteLines.push(lines[i].replace(/^>\s?/, '')); + i++; + } + html.push(`
${renderMarkdown(quoteLines.join('\n'))}
`); + continue; + } + + // Unordered list / task list + if (/^[\s]*[-*+]\s/.test(line)) { + const items: string[] = []; + while (i < lines.length && /^[\s]*[-*+]\s/.test(lines[i])) { + items.push(lines[i].replace(/^[\s]*[-*+]\s/, '')); + i++; + } + const listItems = items.map(item => { + // Task list + if (item.startsWith('[x] ') || item.startsWith('[X] ')) { + return `
  • ${parseInline(item.slice(4))}
  • `; + } + if (item.startsWith('[ ] ')) { + return `
  • ${parseInline(item.slice(4))}
  • `; + } + return `
  • ${parseInline(item)}
  • `; + }); + html.push(`
      ${listItems.join('')}
    `); + continue; + } + + // Ordered list + if (/^\d+\.\s/.test(line)) { + const items: string[] = []; + while (i < lines.length && /^\d+\.\s/.test(lines[i])) { + items.push(lines[i].replace(/^\d+\.\s/, '')); + i++; + } + html.push(`
      ${items.map(item => `
    1. ${parseInline(item)}
    2. `).join('')}
    `); + continue; + } + + // Empty line + if (line.trim() === '') { + i++; + continue; + } + + // Paragraph + const paraLines: string[] = []; + while (i < lines.length && lines[i].trim() !== '' && !lines[i].startsWith('#') && !lines[i].startsWith('```') && !lines[i].startsWith('>') && !/^[-*+]\s/.test(lines[i]) && !/^\d+\.\s/.test(lines[i]) && !/^(\*{3,}|-{3,}|_{3,})\s*$/.test(lines[i])) { + paraLines.push(lines[i]); + i++; + } + if (paraLines.length > 0) { + html.push(`

    ${parseInline(paraLines.join(' '))}

    `); + } + } + + return html.join('\n'); +} diff --git a/@ether/.html/UI/PRIcons.ts b/@ether/.html/UI/PRIcons.ts new file mode 100644 index 00000000..a1008c6b --- /dev/null +++ b/@ether/.html/UI/PRIcons.ts @@ -0,0 +1,36 @@ +// ============================================================ +// PRIcons.ts — SVG icon constants for pull request UI +// ============================================================ + +/** Git commit icon (circle with line) */ +export const COMMIT_SVG = ``; + +/** Git branch icon */ +export const BRANCH_SVG = ``; + +/** Conversation/comment icon */ +export const COMMENT_SVG = ``; + +/** Check/success icon */ +export const CHECK_SVG = ``; + +/** Merge icon */ +export const MERGE_SVG = ``; + +/** Arrow left icon (back navigation) */ +export const ARROW_LEFT_SVG = ``; + +/** Arrow right icon */ +export const ARROW_RIGHT_SVG = ``; + +/** Status change icon (tag/label) */ +export const STATUS_CHANGE_SVG = ``; + +/** File diff icon */ +export const FILE_DIFF_SVG = ``; + +/** Edit/pencil icon */ +export const EDIT_SVG = ``; + +/** Pull request icon (reused from Repository.ts) */ +export const PR_SVG = ``; diff --git a/@ether/.html/UI/PullRequests.ts b/@ether/.html/UI/PullRequests.ts new file mode 100644 index 00000000..f97364bd --- /dev/null +++ b/@ether/.html/UI/PullRequests.ts @@ -0,0 +1,1743 @@ +// ============================================================ +// PullRequests.ts — Pull request list, detail, new PR form, commit diff +// ============================================================ + +import { PHOSPHOR, CRT_SCREEN_BG } from './CRTShell.ts'; +import { renderMarkdown } from './Markdown.ts'; +import { getInlinePullRequests, getCategoryPRSummary, getCategoryPullRequests, getPullRequest, getOpenPRCount, createPullRequest, getRepository, getCurrentPlayer, resolveFile } from './API.ts'; +import type { PullRequest, PRStatus, PRCommit, FileDiff, ActivityItem, InlinePR, CategoryPRSummary } from './API.ts'; +import type { PRParams } from './Router.ts'; +import { computeDiff, renderUnifiedDiff, renderSideBySideDiff } from './DiffView.ts'; +import { + COMMIT_SVG, BRANCH_SVG, COMMENT_SVG, CHECK_SVG, + MERGE_SVG, ARROW_LEFT_SVG, ARROW_RIGHT_SVG, STATUS_CHANGE_SVG, FILE_DIFF_SVG, + EDIT_SVG, PR_SVG, +} from './PRIcons.ts'; + +let styleEl: HTMLStyleElement | null = null; +let currentContainer: HTMLElement | null = null; +let navigateFn: ((path: string) => void) | null = null; +let currentParams: PRParams | null = null; +let currentDiffMode: 'unified' | 'side-by-side' = 'unified'; + + +// ---- Styles ---- + +function injectStyles(): void { + if (styleEl) return; + styleEl = document.createElement('style'); + styleEl.textContent = ` + .pr-page { + max-width: 960px; + margin: 0 auto; + padding: 32px 24px; + font-family: 'Courier New', Courier, monospace; + color: ${PHOSPHOR}; + min-height: 100vh; + box-sizing: border-box; + } + + /* ---- Header chain (same as .repo-header in Repository.ts) ---- */ + .pr-page .repo-header { + display: flex; + align-items: baseline; + gap: 8px; + font-size: 22px; + margin-bottom: 8px; + text-shadow: 0 0 4px rgba(255,255,255,0.5), 0 0 11px rgba(255,255,255,0.22); + } + .pr-page .repo-header .user { color: rgba(255,255,255,0.55); } + .pr-page .repo-header .sep { color: rgba(255,255,255,0.25); } + .pr-page .repo-header .repo-name { color: ${PHOSPHOR}; font-weight: bold; } + .pr-page .repo-header a { color: inherit; text-decoration: none; } + .pr-page .repo-header a:hover { text-decoration: underline; } + + .pr-back-link { + display: inline-flex; + align-items: center; + gap: 6px; + color: rgba(255,255,255,0.5); + text-decoration: none; + font-size: 13px; + margin-bottom: 16px; + cursor: pointer; + } + .pr-back-link:hover { color: ${PHOSPHOR}; } + .pr-back-link svg { width: 16px; height: 16px; fill: currentColor; } + + /* ---- Filter tabs ---- */ + .pr-filter-tabs { + display: flex; + gap: 0; + border-bottom: 1px solid rgba(255,255,255,0.1); + margin-bottom: 0; + } + .pr-filter-tab { + padding: 10px 18px; + font-size: 13px; + color: rgba(255,255,255,0.4); + cursor: pointer; + border: none; + background: none; + border-bottom: 2px solid transparent; + font-family: inherit; + display: flex; + align-items: center; + gap: 6px; + } + .pr-filter-tab:hover { color: rgba(255,255,255,0.65); } + .pr-filter-tab.active { color: ${PHOSPHOR}; border-bottom-color: ${PHOSPHOR}; } + .pr-filter-count { + background: rgba(255,255,255,0.08); + padding: 1px 7px; + border-radius: 10px; + font-size: 11px; + } + + /* ---- PR list ---- */ + .pr-list { + border: 1px solid rgba(255,255,255,0.1); + border-top: none; + border-radius: 0 0 6px 6px; + overflow: hidden; + margin-bottom: 32px; + } + .pr-row { + display: flex; + align-items: flex-start; + padding: 12px 16px; + border-bottom: 1px solid rgba(255,255,255,0.06); + cursor: pointer; + transition: background 0.1s; + gap: 10px; + } + .pr-row:last-child { border-bottom: none; } + .pr-row:hover { background: rgba(255,255,255,0.04); } + .pr-row-icon { + flex-shrink: 0; + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + margin-top: 2px; + } + .pr-row-icon svg { width: 18px; height: 18px; fill: currentColor; } + .pr-row-icon.open { color: #4ade80; } + .pr-row-icon.closed { color: #f87171; } + .pr-row-icon.merged { color: #c084fc; } + .pr-row-body { flex: 1; min-width: 0; } + .pr-row-title { + font-size: 14px; + font-weight: bold; + color: rgba(255,255,255,0.85); + margin-bottom: 2px; + } + .pr-row:hover .pr-row-title { color: ${PHOSPHOR}; } + .pr-row-meta { + font-size: 12px; + color: rgba(255,255,255,0.35); + } + .pr-row-comments { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 4px; + color: rgba(255,255,255,0.3); + font-size: 12px; + } + .pr-row-comments svg { width: 14px; height: 14px; fill: currentColor; } + .pr-empty { + padding: 40px; + text-align: center; + color: rgba(255,255,255,0.25); + font-size: 14px; + } + + /* ---- Folder path prefix on nested PR rows ---- */ + .pr-row-path { + color: rgba(255,255,255,0.3); + font-weight: normal; + } + .pr-row-path::after { + content: ' / '; + color: rgba(255,255,255,0.15); + } + + /* ---- Category rows (@{: String} players, #{: String} worlds) ---- */ + .pr-category-row { + display: flex; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid rgba(255,255,255,0.06); + cursor: pointer; + transition: background 0.1s; + gap: 10px; + text-decoration: none; + color: inherit; + background: rgba(255,255,255,0.015); + } + .pr-category-row:hover { background: rgba(255,255,255,0.05); } + .pr-category-icon { + flex-shrink: 0; + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + color: rgba(255,255,255,0.35); + } + .pr-category-icon svg { width: 18px; height: 18px; fill: currentColor; } + .pr-category-body { flex: 1; min-width: 0; } + .pr-category-name { + font-size: 14px; + font-weight: bold; + color: rgba(255,255,255,0.7); + } + .pr-category-row:hover .pr-category-name { color: ${PHOSPHOR}; } + .pr-category-meta { + font-size: 11px; + color: rgba(255,255,255,0.3); + margin-top: 1px; + } + + /* ---- New PR button ---- */ + .pr-new-btn { + display: inline-flex; + align-items: center; + gap: 6px; + height: 30px; + padding: 0 14px; + border-radius: 6px; + font-family: 'Courier New', Courier, monospace; + font-size: 13px; + cursor: pointer; + background: #00c850; + border: 1px solid #00c850; + color: #0a0a0a; + font-weight: bold; + transition: background 0.15s; + text-decoration: none; + } + .pr-new-btn:hover { background: #00da58; border-color: #00da58; text-decoration: none; } + .pr-new-btn svg { width: 14px; height: 14px; fill: currentColor; } + + .pr-list-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; + } + + /* ---- PR detail ---- */ + .pr-detail-title-row { + display: flex; + align-items: center; + gap: 0; + margin-bottom: 2px; + } + .pr-detail-title { + font-size: 24px; + font-weight: bold; + text-shadow: 0 0 4px rgba(255,255,255,0.3); + } + .pr-detail-id { + color: rgba(255,255,255,0.35); + font-weight: normal; + font-size: 24px; + margin-left: 8px; + } + .pr-detail-meta { + font-size: 13px; + color: rgba(255,255,255,0.4); + margin-bottom: 4px; + } + .pr-detail-meta a { + color: rgba(255,255,255,0.6); + text-decoration: none; + } + .pr-detail-meta a:hover { color: ${PHOSPHOR}; text-decoration: underline; } + .pr-status-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 10px; + border-radius: 12px; + font-size: 12px; + font-weight: bold; + margin-left: 10px; + vertical-align: middle; + } + .pr-status-badge svg { width: 14px; height: 14px; fill: currentColor; } + .pr-status-badge.open { background: rgba(74,222,128,0.15); color: #4ade80; } + .pr-status-badge.closed { background: rgba(248,113,113,0.15); color: #f87171; } + .pr-status-badge.merged { background: rgba(192,132,252,0.15); color: #c084fc; } + + /* ---- Branch info (inline below title) ---- */ + .pr-branch-info { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + line-height: 1; + color: rgba(255,255,255,0.4); + margin-bottom: 16px; + } + .pr-branch-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 18px; + height: 18px; + } + .pr-branch-icon svg { width: 18px; height: 18px; fill: currentColor; display: block; } + .pr-branch-label { + background: rgba(255,255,255,0.06); + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; + color: rgba(255,255,255,0.65); + text-decoration: none; + cursor: pointer; + } + .pr-branch-label:hover { color: ${PHOSPHOR}; background: rgba(255,255,255,0.1); } + .pr-branch-arrow { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: rgba(255,255,255,0.2); + } + .pr-branch-arrow svg { width: 14px; height: 14px; fill: currentColor; display: block; } + + /* ---- Hover edit buttons ---- */ + .pr-editable { + position: relative; + display: flex; + align-items: center; + gap: 0; + } + .pr-editable .pr-hover-edit { + opacity: 0; + transition: opacity 0.15s; + margin-left: 8px; + flex-shrink: 0; + } + .pr-editable:hover .pr-hover-edit { opacity: 1; } + .pr-hover-edit { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 4px; + border: none; + background: none; + cursor: pointer; + color: rgba(255,255,255,0.3); + padding: 0; + transition: color 0.15s; + } + .pr-hover-edit:hover { color: rgba(255,255,255,0.7); } + .pr-hover-edit svg { width: 16px; height: 16px; fill: currentColor; } + + /* Timeline comment hover edit */ + .pr-timeline-item .pr-hover-edit { + opacity: 0; + position: absolute; + top: 4px; + right: 4px; + } + .pr-timeline-item:hover .pr-hover-edit { opacity: 1; } + + /* ---- Inline editing styles ---- */ + .pr-inline-title-input { + font-size: 24px; + font-weight: bold; + text-shadow: 0 0 4px rgba(255,255,255,0.3); + background: transparent; + border: none; + border-bottom: 1px solid rgba(255,255,255,0.2); + color: ${PHOSPHOR}; + font-family: 'Courier New', Courier, monospace; + width: 100%; + padding: 0; + outline: none; + } + .pr-inline-title-input:focus { border-bottom-color: rgba(255,255,255,0.4); } + .pr-inline-desc-textarea { + width: 100%; + min-height: 80px; + background: transparent; + border: none; + border-bottom: 1px solid rgba(255,255,255,0.15); + color: rgba(255,255,255,0.7); + font-family: 'Courier New', Courier, monospace; + font-size: 14px; + line-height: 1.6; + resize: vertical; + padding: 0; + outline: none; + box-sizing: border-box; + } + .pr-inline-desc-textarea:focus { border-bottom-color: rgba(255,255,255,0.3); } + .pr-inline-comment-textarea { + width: 100%; + min-height: 40px; + background: transparent; + border: none; + border-bottom: 1px solid rgba(255,255,255,0.15); + color: rgba(255,255,255,0.7); + font-family: 'Courier New', Courier, monospace; + font-size: 14px; + line-height: 1.6; + resize: vertical; + padding: 0; + outline: none; + box-sizing: border-box; + } + .pr-inline-comment-textarea:focus { border-bottom-color: rgba(255,255,255,0.3); } + + /* ---- Merge section ---- */ + .pr-merge-section { + border: 1px solid rgba(74,222,128,0.2); + border-radius: 6px; + padding: 16px; + margin-bottom: 24px; + display: flex; + align-items: center; + gap: 12px; + background: rgba(74,222,128,0.03); + } + .pr-merge-check { + color: #4ade80; + display: flex; + align-items: center; + } + .pr-merge-check svg { width: 20px; height: 20px; fill: currentColor; } + .pr-merge-text { + flex: 1; + font-size: 13px; + color: rgba(255,255,255,0.6); + } + .pr-merge-btn { + display: inline-flex; + align-items: center; + gap: 6px; + height: 32px; + padding: 0 16px; + border-radius: 6px; + font-family: 'Courier New', Courier, monospace; + font-size: 13px; + cursor: pointer; + background: #00c850; + border: 1px solid #00c850; + color: #0a0a0a; + font-weight: bold; + transition: background 0.15s; + } + .pr-merge-btn:hover { background: #00da58; } + .pr-merge-btn svg { width: 14px; height: 14px; fill: currentColor; } + + /* ---- Activity timeline ---- */ + .pr-timeline { margin-bottom: 24px; } + .pr-timeline-section-label { + font-size: 13px; + color: rgba(255,255,255,0.35); + margin: 20px 0 6px; + padding-bottom: 4px; + border-bottom: 1px solid rgba(255,255,255,0.06); + } + .pr-timeline-item { + display: flex; + gap: 10px; + padding: 6px 0; + border-bottom: 1px solid rgba(255,255,255,0.04); + position: relative; + } + .pr-timeline-item:last-child { border-bottom: none; } + .pr-timeline-icon { + flex-shrink: 0; + width: 28px; + height: 28px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255,255,255,0.05); + overflow: hidden; + } + .pr-timeline-icon svg { width: 14px; height: 14px; fill: currentColor; } + .pr-timeline-icon img { width: 28px; height: 28px; object-fit: cover; border-radius: 50%; } + .pr-timeline-icon.commit { color: #60a5fa; } + .pr-timeline-icon.comment { color: rgba(255,255,255,0.5); } + .pr-timeline-icon.status { color: #fbbf24; } + .pr-timeline-icon.merge { color: #c084fc; } + .pr-timeline-body { + flex: 1; + min-width: 0; + overflow-wrap: break-word; + word-wrap: break-word; + word-break: break-word; + } + .pr-timeline-header { + font-size: 13px; + color: rgba(255,255,255,0.5); + margin-bottom: 2px; + } + .pr-timeline-header strong { color: rgba(255,255,255,0.8); } + .pr-timeline-header a { + color: #60a5fa; + text-decoration: none; + cursor: pointer; + } + .pr-timeline-header a:hover { text-decoration: underline; } + .pr-timeline-header .pr-user-link { + color: rgba(255,255,255,0.8); + text-decoration: none; + font-weight: bold; + } + .pr-timeline-header .pr-user-link:hover { text-decoration: underline; color: ${PHOSPHOR}; } + .pr-timeline-content { + font-size: 14px; + line-height: 1.6; + color: rgba(255,255,255,0.7); + overflow-wrap: break-word; + word-wrap: break-word; + word-break: break-word; + } + .pr-timeline-content p { margin: 0 0 8px 0; } + .pr-timeline-content p:last-child { margin-bottom: 0; } + .pr-timeline-content code { + background: rgba(255,255,255,0.08); + padding: 2px 6px; + border-radius: 3px; + font-size: 13px; + } + .pr-timeline-time { + font-size: 11px; + color: rgba(255,255,255,0.25); + } + + /* Grouped consecutive comments — continuation without icon/name */ + .pr-timeline-item.grouped { + padding-left: 38px; /* 28px icon + 10px gap */ + padding-top: 0; + border-bottom: none; + } + .pr-timeline-item.grouped .pr-timeline-header { display: none; } + .pr-timeline-item.group-start { border-bottom: none; } + + /* ---- Comment form ---- */ + .pr-comment-form { + border: 1px solid rgba(255,255,255,0.1); + border-radius: 6px; + overflow: hidden; + margin-bottom: 24px; + } + .pr-comment-form textarea { + width: 100%; + min-height: 80px; + padding: 12px 16px; + background: rgba(255,255,255,0.02); + border: none; + border-bottom: 1px solid rgba(255,255,255,0.08); + color: rgba(255,255,255,0.8); + font-family: 'Courier New', Courier, monospace; + font-size: 13px; + resize: vertical; + box-sizing: border-box; + } + .pr-comment-form textarea:focus { outline: 1px solid rgba(255,255,255,0.2); } + .pr-comment-form-actions { + display: flex; + justify-content: flex-end; + padding: 8px 12px; + background: rgba(255,255,255,0.02); + } + .pr-comment-submit { + height: 28px; + padding: 0 14px; + border-radius: 6px; + font-family: 'Courier New', Courier, monospace; + font-size: 12px; + cursor: pointer; + background: rgba(255,255,255,0.08); + border: 1px solid rgba(255,255,255,0.15); + color: rgba(255,255,255,0.65); + transition: background 0.15s, color 0.15s; + } + .pr-comment-submit:hover { background: rgba(255,255,255,0.12); color: ${PHOSPHOR}; } + + /* ---- Commit diff page ---- */ + .pr-commit-header { + border: 1px solid rgba(255,255,255,0.1); + border-radius: 6px; + padding: 16px; + margin-bottom: 20px; + background: rgba(255,255,255,0.02); + } + .pr-commit-message { + font-size: 16px; + font-weight: bold; + margin-bottom: 6px; + } + .pr-commit-meta { + font-size: 12px; + color: rgba(255,255,255,0.35); + } + .pr-diff-toggle { + display: flex; + gap: 0; + margin-bottom: 12px; + } + .pr-diff-toggle-btn { + padding: 6px 14px; + font-size: 12px; + font-family: 'Courier New', Courier, monospace; + cursor: pointer; + background: none; + border: 1px solid rgba(255,255,255,0.12); + color: rgba(255,255,255,0.4); + transition: background 0.15s, color 0.15s; + } + .pr-diff-toggle-btn:first-child { border-radius: 4px 0 0 4px; } + .pr-diff-toggle-btn:last-child { border-radius: 0 4px 4px 0; border-left: none; } + .pr-diff-toggle-btn.active { + background: rgba(255,255,255,0.08); + color: ${PHOSPHOR}; + border-color: rgba(255,255,255,0.2); + } + .pr-diff-file { + border: 1px solid rgba(255,255,255,0.1); + border-radius: 6px; + overflow: hidden; + margin-bottom: 16px; + } + .pr-diff-file-header { + padding: 8px 16px; + font-size: 13px; + font-weight: bold; + color: rgba(255,255,255,0.6); + border-bottom: 1px solid rgba(255,255,255,0.1); + background: rgba(255,255,255,0.02); + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + } + .pr-diff-file-header svg { width: 16px; height: 16px; fill: currentColor; flex-shrink: 0; } + .pr-diff-file-header:hover { background: rgba(255,255,255,0.04); } + .pr-diff-file-type { + font-size: 11px; + padding: 1px 6px; + border-radius: 3px; + font-weight: normal; + } + .pr-diff-file-type.added { background: rgba(74,222,128,0.15); color: #4ade80; } + .pr-diff-file-type.modified { background: rgba(96,165,250,0.15); color: #60a5fa; } + .pr-diff-file-type.deleted { background: rgba(248,113,113,0.15); color: #f87171; } + .pr-diff-file-body { + overflow-x: auto; + font-size: 13px; + line-height: 20px; + } + .pr-diff-file-body.collapsed { display: none; } + + /* ---- Diff lines ---- */ + .diff-unified, .diff-side-by-side { width: 100%; } + .diff-line { + display: flex; + height: 20px; + line-height: 20px; + font-family: 'Courier New', Courier, monospace; + } + .diff-line-add { background: rgba(0,200,80,0.08); } + .diff-line-remove { background: rgba(255,60,60,0.08); } + .diff-line-context { background: transparent; } + .diff-line-num { + flex: 0 0 50px; + text-align: right; + padding-right: 8px; + color: rgba(255,255,255,0.2); + font-size: 12px; + user-select: none; + -webkit-user-select: none; + } + .diff-line-prefix { + flex: 0 0 20px; + text-align: center; + color: rgba(255,255,255,0.3); + user-select: none; + -webkit-user-select: none; + } + .diff-line-add .diff-line-prefix { color: #4ade80; } + .diff-line-remove .diff-line-prefix { color: #f87171; } + .diff-line-text { + flex: 1; + white-space: pre; + color: rgba(255,255,255,0.75); + overflow-x: hidden; + } + .diff-line-add .diff-line-text { color: #4ade80; } + .diff-line-remove .diff-line-text { color: #f87171; } + + /* ---- Side-by-side ---- */ + .diff-sbs-row { display: flex; } + .diff-sbs-left, .diff-sbs-right { + flex: 1; + display: flex; + height: 20px; + line-height: 20px; + font-family: 'Courier New', Courier, monospace; + min-width: 0; + } + .diff-sbs-left { border-right: 1px solid rgba(255,255,255,0.06); } + .diff-sbs-left.diff-line-remove { background: rgba(255,60,60,0.08); } + .diff-sbs-right.diff-line-add { background: rgba(0,200,80,0.08); } + .diff-sbs-left.diff-line-empty, + .diff-sbs-right.diff-line-empty { background: rgba(255,255,255,0.015); } + .diff-sbs-left .diff-line-text, + .diff-sbs-right .diff-line-text { + flex: 1; + white-space: pre; + color: rgba(255,255,255,0.75); + overflow-x: hidden; + } + .diff-sbs-left.diff-line-remove .diff-line-text { color: #f87171; } + .diff-sbs-right.diff-line-add .diff-line-text { color: #4ade80; } + .diff-sbs-left .diff-line-num, + .diff-sbs-right .diff-line-num { + flex: 0 0 40px; + text-align: right; + padding-right: 8px; + color: rgba(255,255,255,0.2); + font-size: 12px; + user-select: none; + -webkit-user-select: none; + } + + /* ---- New PR form ---- */ + .pr-form-input { + width: 100%; + padding: 0; + background: transparent; + border: none; + border-bottom: 1px solid rgba(255,255,255,0.2); + color: ${PHOSPHOR}; + font-family: 'Courier New', Courier, monospace; + font-size: 24px; + font-weight: bold; + box-sizing: border-box; + margin-bottom: 4px; + text-shadow: 0 0 4px rgba(255,255,255,0.3); + outline: none; + } + .pr-form-input:focus { border-bottom-color: rgba(255,255,255,0.4); } + .pr-form-input::placeholder { color: rgba(255,255,255,0.2); } + .pr-form-textarea { + width: 100%; + min-height: 80px; + padding: 0; + background: transparent; + border: none; + border-bottom: 1px solid rgba(255,255,255,0.15); + color: rgba(255,255,255,0.7); + font-family: 'Courier New', Courier, monospace; + font-size: 14px; + line-height: 1.6; + resize: vertical; + box-sizing: border-box; + outline: none; + } + .pr-form-textarea:focus { border-bottom-color: rgba(255,255,255,0.3); } + .pr-form-textarea::placeholder { color: rgba(255,255,255,0.2); } + .pr-form-select { + padding: 2px 8px; + background: rgba(255,255,255,0.03); + border: 1px solid rgba(255,255,255,0.12); + border-radius: 4px; + color: rgba(255,255,255,0.65); + font-family: 'Courier New', Courier, monospace; + font-size: 12px; + cursor: pointer; + } + .pr-form-select:focus { outline: 1px solid rgba(255,255,255,0.25); } + .pr-form-actions { + display: flex; + gap: 10px; + margin-top: 16px; + } + .pr-create-btn { + display: inline-flex; + align-items: center; + gap: 6px; + height: 32px; + padding: 0 16px; + border-radius: 6px; + font-family: 'Courier New', Courier, monospace; + font-size: 13px; + cursor: pointer; + background: #00c850; + border: 1px solid #00c850; + color: #0a0a0a; + font-weight: bold; + transition: background 0.15s; + } + .pr-create-btn:hover { background: #00da58; } + .pr-cancel-btn { + display: inline-flex; + align-items: center; + height: 32px; + padding: 0 16px; + border-radius: 6px; + font-family: 'Courier New', Courier, monospace; + font-size: 13px; + cursor: pointer; + background: none; + border: 1px solid rgba(255,255,255,0.15); + color: rgba(255,255,255,0.55); + text-decoration: none; + transition: border-color 0.15s, color 0.15s; + } + .pr-cancel-btn:hover { border-color: rgba(255,255,255,0.3); color: rgba(255,255,255,0.75); text-decoration: none; } + + @media (max-width: 640px) { + .pr-page { padding: 16px 12px; } + .pr-detail-title { font-size: 18px; } + .pr-branch-info { flex-wrap: wrap; } + .diff-sbs-row { flex-direction: column; } + .diff-sbs-left { border-right: none; border-bottom: 1px solid rgba(255,255,255,0.06); } + } + `; + document.head.appendChild(styleEl); +} + +// ---- Helpers ---- + +function escapeHtml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +function formatTime(iso: string): string { + const d = new Date(iso); + const now = Date.now(); + const diff = now - d.getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d ago`; + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); +} + +function statusIcon(status: PRStatus): string { + if (status === 'merged') return `${MERGE_SVG}`; + if (status === 'closed') return `${BRANCH_SVG}`; + return `${BRANCH_SVG}`; +} + +function statusBadge(status: PRStatus): string { + const labels: Record = { open: 'Open', closed: 'Closed', merged: 'Merged' }; + return `${labels[status]}`; +} + +function buildPullsUrl(params: PRParams, suffix?: string): string { + const base = params.base || ''; + const pathPart = params.path.length > 0 ? '/' + params.path.join('/') : ''; + const categoryPart = params.category ? '/' + params.category : ''; + return `${base}${pathPart}/-${categoryPart}/pulls${suffix ? '/' + suffix : ''}`; +} + +function buildRepoUrl(params: PRParams): string { + const base = params.base || ''; + const pathPart = params.path.length > 0 ? '/' + params.path.join('/') : ''; + return `${base}${pathPart}` || '/'; +} + +function buildUserUrl(user: string): string { + return `/@${user}`; +} + +/** Get the raw file URL for a user's profile picture. + * userPath is the full canonical path, e.g. '@alice' or '@ether/~genesis/@alice'. */ +async function getUserProfilePic(userPath: string): Promise { + // Extract username from the last @-prefixed segment + const segments = userPath.split('/'); + const userSeg = [...segments].reverse().find(s => s.startsWith('@')); + const user = userSeg ? userSeg.slice(1) : segments[segments.length - 1]; + + const repo = await getRepository(user); + if (!repo) return null; + const names = ['2d-square.svg', '2d-square.png', '2d-square.jpeg']; + for (const name of names) { + if (resolveFile(repo.tree, ['avatar', name])) { + return `/**/${userPath}/avatar/${name}`; + } + } + return null; +} + +/** Render a user avatar icon — profile picture if available, otherwise fallback SVG. + * userPath is the full canonical path, e.g. '@alice' or '@ether/~genesis/@alice'. */ +async function renderUserIcon(userPath: string, fallbackSvg: string, cssClass: string): Promise { + const pic = await getUserProfilePic(userPath); + if (pic) { + return `
    ${escapeHtml(userPath)}
    `; + } + return `
    ${fallbackSvg}
    `; +} + +/** Render a clickable username */ +function userLink(user: string): string { + return `@${escapeHtml(user)}`; +} + +/** Render a clickable branch label */ +function branchLink(label: string): string { + // Branch labels like "alice/superposition" link to /@alice/superposition + // Branch labels like "main" just render as text + const href = label.includes('/') ? `/@${label}` : '#'; + return `${escapeHtml(label)}`; +} + +// ---- Header chain (reuses Repository.ts .repo-header pattern) ---- + +function renderHeaderChain(params: PRParams): string { + const base = params.base || ''; + + let html = `
    `; + // Only show @user breadcrumb when /@user is actually in the URL + if (base) { + const userPullsUrl = `${base}/-/pulls`; + html += `@${escapeHtml(params.user)}`; + } + // Path segments → cumulative PR lists using the actual URL base + for (let i = 0; i < params.path.length; i++) { + if (base || i > 0) html += `/`; + const isLast = i === params.path.length - 1; + const cls = isLast ? 'repo-name' : 'user'; + const segPath = params.path.slice(0, i + 1).join('/'); + const segPullsUrl = `${base}/${segPath}/-/pulls`; + html += `${escapeHtml(displaySegment(params.path[i]))}`; + } + html += `
    `; + return html; +} + +// ---- Render: PR List ---- + +async function renderPRList(params: PRParams): Promise { + const inlinePRs = await getInlinePullRequests(params.repoPath); + const playerSummary = await getCategoryPRSummary(params.repoPath, '@'); + const worldSummary = await getCategoryPRSummary(params.repoPath, '~'); + + const openPRs = inlinePRs.filter(({ pr }) => pr.status === 'open') + .sort((a, b) => new Date(b.pr.updatedAt).getTime() - new Date(a.pr.updatedAt).getTime()); + const closedPRs = inlinePRs.filter(({ pr }) => pr.status === 'closed' || pr.status === 'merged') + .sort((a, b) => new Date(b.pr.updatedAt).getTime() - new Date(a.pr.updatedAt).getTime()); + + let html = `
    `; + html += renderHeaderChain(params); + + html += `${ARROW_LEFT_SVG} Back to code`; + + html += `
    + Pull Requests + ${PR_SVG} New Pull Request +
    `; + + html += `
    + + +
    `; + + html += `
    `; + + // Category rows: Players (@) and Worlds (~) — open + closed variants + if (playerSummary) { + html += renderCategoryRow(playerSummary, '@', 'open', params); + html += renderCategoryRow(playerSummary, '@', 'closed', params); + } + if (worldSummary) { + html += renderCategoryRow(worldSummary, '~', 'open', params); + html += renderCategoryRow(worldSummary, '~', 'closed', params); + } + + // Open PRs (visible by default) — each shows its folder path prefix + for (const { pr, relPath } of openPRs) { + html += renderPRRow(pr, 'open', false, relPath); + } + // Closed/Merged PRs (hidden by default) + for (const { pr, relPath } of closedPRs) { + html += renderPRRow(pr, 'closed', true, relPath); + } + + if (inlinePRs.length === 0 && !playerSummary && !worldSummary) { + html += `
    No pull requests yet.
    `; + } + + html += `
    `; + return html; +} + +function renderCategoryRow(summary: CategoryPRSummary, prefix: '@' | '~', filterGroup: 'open' | 'closed', params: PRParams): string { + const label = prefix === '@' ? '@{: String}' : '#{: String}'; + const kindLabel = prefix === '@' + ? (summary.itemCount === 1 ? 'Player' : 'Players') + : (summary.itemCount === 1 ? 'World' : 'Worlds'); + const categoryUrl = `${params.base || ''}${params.path.length > 0 ? '/' + params.path.join('/') : ''}/-/${prefix}/pulls`; + const fullUrl = filterGroup === 'closed' ? categoryUrl + '?filter=closed' : categoryUrl; + const hidden = filterGroup === 'closed' ? ' style="display:none"' : ''; + const count = filterGroup === 'open' ? summary.openCount : summary.closedCount; + if (count === 0) return ''; + const verb = filterGroup === 'open' ? 'open across' : 'closed in'; + const meta = `${count} ${verb} ${summary.itemCount} ${kindLabel}`; + + let html = ``; + html += `
    ${PR_SVG}
    `; + html += `
    `; + html += `
    ${escapeHtml(label)}
    `; + html += `
    ${meta}
    `; + html += `
    `; + html += `
    `; + return html; +} + +// ---- Render: Category list (players or worlds) ---- + +async function renderCategoryList(params: PRParams): Promise { + const prefix = params.category!; + const categoryLabel = prefix === '@' ? '@{: String}' : '#{: String}'; + const prs = await getCategoryPullRequests(params.repoPath, prefix); + + const openPRs = prs.filter(({ pr }) => pr.status === 'open') + .sort((a, b) => new Date(b.pr.updatedAt).getTime() - new Date(a.pr.updatedAt).getTime()); + const closedPRs = prs.filter(({ pr }) => pr.status === 'closed' || pr.status === 'merged') + .sort((a, b) => new Date(b.pr.updatedAt).getTime() - new Date(a.pr.updatedAt).getTime()); + + let html = `
    `; + html += renderHeaderChain(params); + + // Back link to main PR list + const mainListParams = { ...params, category: null as '@' | '~' | null }; + html += `${ARROW_LEFT_SVG} Back to pull requests`; + + html += `
    + ${escapeHtml(categoryLabel)} +
    `; + + html += `
    + + +
    `; + + html += `
    `; + + for (const { pr, relPath } of openPRs) { + html += renderPRRow(pr, 'open', false, relPath); + } + for (const { pr, relPath } of closedPRs) { + html += renderPRRow(pr, 'closed', true, relPath); + } + + if (prs.length === 0) { + html += `
    No pull requests in this category.
    `; + } + + html += `
    `; + return html; +} + +/** Convert a single URL-style segment to display: ~foo → #foo (worlds use # for display, ~ in URLs) */ +function displaySegment(seg: string): string { + return seg.startsWith('~') ? '#' + seg.slice(1) : seg; +} + +/** Convert URL-style path segments to display: ~ → # (worlds use # for display, ~ in URLs) */ +function displayPath(relPath: string): string { + return relPath.split('/').map(displaySegment).join('/'); +} + +function renderPRRow(pr: PullRequest, filterGroup: string, hidden = false, relPath = ''): string { + const display = hidden ? ' style="display:none"' : ''; + const pathPrefix = relPath ? `${escapeHtml(displayPath(relPath))}` : ''; + let html = `
    `; + html += statusIcon(pr.status); + html += `
    +
    ${pathPrefix}${escapeHtml(pr.title)}
    +
    #${pr.id} opened ${formatTime(pr.createdAt)} by ${escapeHtml(pr.author)}
    +
    `; + if (pr.comments.length > 0) { + html += `
    ${COMMENT_SVG} ${pr.comments.length}
    `; + } + html += `
    `; + return html; +} + +// ---- Render shared: title block + branch info ---- + +function renderTitleBlock(pr: PullRequest, params: PRParams): string { + let html = ''; + + // Title + status badge (with hover edit) + html += `
    `; + html += `
    `; + html += `${escapeHtml(pr.title)}`; + html += `#${pr.id}`; + html += statusBadge(pr.status); + html += `
    `; + if (pr.status === 'open') { + html += ``; + } + html += `
    `; + + // "by @author" line + html += `
    by ${userLink(pr.author)}
    `; + + // Branch info (inline, with hover edit) + html += `
    `; + html += `${BRANCH_SVG}`; + html += branchLink(pr.sourceLabel); + html += `${ARROW_RIGHT_SVG}`; + html += branchLink(pr.targetLabel); + if (pr.status === 'open') { + html += ``; + } + html += `
    `; + + return html; +} + +// ---- Render shared: description as a comment-style block ---- + +async function renderDescriptionBlock(pr: PullRequest): Promise { + let html = ''; + html += ``; + html += `
    `; + html += await renderUserIcon(`@${pr.author}`, COMMENT_SVG, 'comment'); + html += `
    `; + html += `
    ${userLink(pr.author)} ${formatTime(pr.createdAt)}
    `; + html += `
    ${renderMarkdown(pr.description)}
    `; + html += `
    `; + if (pr.status === 'open') { + html += ``; + } + html += `
    `; + return html; +} + +// ---- Render: PR Detail ---- + +async function renderPRDetail(params: PRParams): Promise { + const pr = await getPullRequest(params.repoPath, params.prId!); + if (!pr) { + return `
    ${renderHeaderChain(params)}
    Pull request #${params.prId} not found.
    `; + } + + let html = `
    `; + html += renderHeaderChain(params); + + // Back link + html += `${ARROW_LEFT_SVG} Back to pull requests`; + + // Title + meta + branch + html += renderTitleBlock(pr, params); + + // Description (same style as a comment) + html += await renderDescriptionBlock(pr); + + // Merge section (only for open + mergeable) + if (pr.status === 'open' && pr.mergeable) { + html += `
    `; + html += `
    ${CHECK_SVG}
    `; + html += `
    All checks passed. This branch has no conflicts with the target branch.
    `; + html += ``; + html += `
    `; + } + + // Activity timeline + if (pr.activity.length > 0) { + html += ``; + html += `
    `; + let prevCommentAuthor: string | null = null; + let prevCommentTime: number | null = null; + for (let ai = 0; ai < pr.activity.length; ai++) { + const item = pr.activity[ai]; + const isComment = item.type === 'comment'; + let isGrouped = false; + if (isComment && prevCommentAuthor === item.comment.author && prevCommentTime !== null) { + const gap = new Date(item.createdAt).getTime() - prevCommentTime; + isGrouped = gap <= 60_000; // group only if within 60 seconds + } + + // Check if this comment starts a group (next item is grouped with it) + let isGroupStart = false; + if (isComment && !isGrouped) { + const next = pr.activity[ai + 1]; + if (next && next.type === 'comment' && next.comment.author === item.comment.author) { + const gap = new Date(next.createdAt).getTime() - new Date(item.createdAt).getTime(); + isGroupStart = gap <= 60_000; + } + } + + html += await renderActivityItem(item, params, pr, isGrouped, isGroupStart); + prevCommentAuthor = isComment ? item.comment.author : null; + prevCommentTime = isComment ? new Date(item.createdAt).getTime() : null; + } + html += `
    `; + } + + // Comment form (only for open PRs) + if (pr.status === 'open') { + html += `
    `; + html += ``; + html += `
    `; + html += ``; + html += `
    `; + } + + html += `
    `; + return html; +} + +async function renderActivityItem(item: ActivityItem, params: PRParams, pr: PullRequest, isGrouped: boolean = false, isGroupStart: boolean = false): Promise { + let html = ''; + + switch (item.type) { + case 'commit': { + html += `
    `; + html += `
    ${COMMIT_SVG}
    `; + html += `
    `; + const commitUrl = buildPullsUrl(params, `${pr.id}/commits/${item.commit.id}`); + html += `
    ${userLink(item.commit.author)} committed ${escapeHtml(item.commit.message)}
    `; + html += `
    ${formatTime(item.createdAt)}
    `; + html += `
    `; + html += `
    `; + break; + } + case 'comment': { + const groupedClass = isGrouped ? ' grouped' : (isGroupStart ? ' group-start' : ''); + html += `
    `; + if (!isGrouped) { + html += await renderUserIcon(`@${item.comment.author}`, COMMENT_SVG, 'comment'); + } + html += `
    `; + if (!isGrouped) { + html += `
    ${userLink(item.comment.author)} ${formatTime(item.createdAt)}
    `; + } + html += `
    ${renderMarkdown(item.comment.body)}
    `; + html += `
    `; + if (pr.status === 'open') { + html += ``; + } + html += `
    `; + break; + } + case 'status_change': { + html += `
    `; + html += `
    ${STATUS_CHANGE_SVG}
    `; + html += `
    `; + html += `
    ${userLink(item.author)} changed status from ${item.from} to ${item.to}
    `; + html += `
    ${formatTime(item.createdAt)}
    `; + html += `
    `; + html += `
    `; + break; + } + case 'merge': { + html += `
    `; + html += `
    ${MERGE_SVG}
    `; + html += `
    `; + html += `
    ${userLink(item.author)} merged this pull request
    `; + html += `
    ${formatTime(item.createdAt)}
    `; + html += `
    `; + html += `
    `; + break; + } + } + + return html; +} + +// ---- Render: Commit Diff ---- + +async function renderCommitDiff(params: PRParams): Promise { + const pr = await getPullRequest(params.repoPath, params.prId!); + if (!pr) { + return `
    ${renderHeaderChain(params)}
    Pull request not found.
    `; + } + + const commit = pr.commits.find(c => c.id === params.commitId); + if (!commit) { + return `
    ${renderHeaderChain(params)}
    Commit not found.
    `; + } + + let html = `
    `; + html += renderHeaderChain(params); + + // Back link to PR detail + html += `${ARROW_LEFT_SVG} Back to #${pr.id}`; + + // Commit header + html += `
    `; + html += `
    ${escapeHtml(commit.message)}
    `; + html += `
    ${escapeHtml(commit.author)} committed ${formatTime(commit.createdAt)} · ${commit.id.slice(0, 8)}
    `; + html += `
    `; + + // Diff mode toggle + html += `
    `; + html += ``; + html += ``; + html += `
    `; + + // File diffs + for (const diff of commit.diffs) { + html += renderFileDiff(diff); + } + + html += `
    `; + return html; +} + +function renderFileDiff(fileDiff: FileDiff): string { + const diffLines = computeDiff(fileDiff.oldContent, fileDiff.newContent); + const diffHtml = currentDiffMode === 'unified' + ? renderUnifiedDiff(diffLines) + : renderSideBySideDiff(diffLines); + + let html = `
    `; + html += `
    `; + html += FILE_DIFF_SVG; + html += `${escapeHtml(fileDiff.path)}`; + html += `${fileDiff.type}`; + html += `
    `; + html += `
    ${diffHtml}
    `; + html += `
    `; + return html; +} + +// ---- Render: New PR form (same layout as detail but editable) ---- + +async function renderNewPRForm(params: PRParams): Promise { + const currentUser = getCurrentPlayer(); + + let html = `
    `; + html += renderHeaderChain(params); + + // Back link + html += `${ARROW_LEFT_SVG} Back to pull requests`; + + // Editable title (same look as rendered title) + html += ``; + + // "by @currentUser" line + html += `
    by ${userLink(currentUser)}
    `; + + // Branch info (inline, with editable target) + html += `
    `; + html += `${BRANCH_SVG}`; + html += `feature/new-branch`; + html += `${ARROW_RIGHT_SVG}`; + html += ``; + html += `
    `; + + // Description (same comment-style as detail, but with textarea) + html += ``; + html += `
    `; + html += await renderUserIcon(`@${currentUser}`, COMMENT_SVG, 'comment'); + html += `
    `; + html += `
    ${userLink(currentUser)}
    `; + html += ``; + html += `
    `; + + // Actions + html += `
    `; + html += ``; + html += `Cancel`; + html += `
    `; + + html += `
    `; + return html; +} + +// ---- Inline editing ---- + +async function startEditTitle(): Promise { + if (!currentContainer || !currentParams) return; + const pr = await getPullRequest(currentParams.repoPath, currentParams.prId!); + if (!pr) return; + + const display = currentContainer.querySelector('[data-edit-display="title"]') as HTMLElement; + if (!display) return; + + const wrapper = display.closest('.pr-editable') as HTMLElement; + if (!wrapper) return; + + // Replace the title-row content with an input + const titleRow = wrapper.querySelector('.pr-detail-title-row') as HTMLElement; + if (!titleRow) return; + + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'pr-inline-title-input'; + input.value = pr.title; + titleRow.replaceWith(input); + input.focus(); + input.select(); + + // Hide edit button while editing + const editBtn = wrapper.querySelector('.pr-hover-edit') as HTMLElement; + if (editBtn) editBtn.style.display = 'none'; + + const save = () => { + const newTitle = input.value.trim(); + if (newTitle && newTitle !== pr.title) { + pr.title = newTitle; + pr.updatedAt = new Date().toISOString(); + } + render(); + }; + + input.addEventListener('blur', save); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); save(); } + if (e.key === 'Escape') { render(); } + }); +} + +async function startEditDescription(): Promise { + if (!currentContainer || !currentParams) return; + const pr = await getPullRequest(currentParams.repoPath, currentParams.prId!); + if (!pr) return; + + const display = currentContainer.querySelector('[data-edit-display="description"]') as HTMLElement; + if (!display) return; + + const textarea = document.createElement('textarea'); + textarea.className = 'pr-inline-desc-textarea'; + textarea.value = pr.description; + display.replaceWith(textarea); + textarea.focus(); + + // Hide edit button + const item = textarea.closest('.pr-timeline-item') as HTMLElement; + const editBtn = item?.querySelector('.pr-hover-edit') as HTMLElement; + if (editBtn) editBtn.style.display = 'none'; + + const save = () => { + const newDesc = textarea.value.trim(); + if (newDesc !== pr.description) { + pr.description = newDesc; + pr.updatedAt = new Date().toISOString(); + } + render(); + }; + + textarea.addEventListener('blur', save); + textarea.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { render(); } + }); +} + +async function startEditComment(commentId: number): Promise { + if (!currentContainer || !currentParams) return; + const pr = await getPullRequest(currentParams.repoPath, currentParams.prId!); + if (!pr) return; + + const comment = pr.comments.find(c => c.id === commentId); + if (!comment) return; + + const display = currentContainer.querySelector(`[data-edit-display="comment-${commentId}"]`) as HTMLElement; + if (!display) return; + + const textarea = document.createElement('textarea'); + textarea.className = 'pr-inline-comment-textarea'; + textarea.value = comment.body; + display.replaceWith(textarea); + textarea.focus(); + + // Hide edit button + const item = textarea.closest('.pr-timeline-item') as HTMLElement; + const editBtn = item?.querySelector('.pr-hover-edit') as HTMLElement; + if (editBtn) editBtn.style.display = 'none'; + + const save = () => { + const newBody = textarea.value.trim(); + if (newBody && newBody !== comment.body) { + comment.body = newBody; + pr.updatedAt = new Date().toISOString(); + // Also update the matching activity item + for (const act of pr.activity) { + if (act.type === 'comment' && act.comment.id === commentId) { + act.comment.body = newBody; + } + } + } + render(); + }; + + textarea.addEventListener('blur', save); + textarea.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { render(); } + }); +} + +async function startEditBranch(): Promise { + if (!currentContainer || !currentParams) return; + const pr = await getPullRequest(currentParams.repoPath, currentParams.prId!); + if (!pr) return; + + const branchInfo = currentContainer.querySelector('[data-edit-target="branch"]') as HTMLElement; + if (!branchInfo) return; + + // Replace the branch labels with inputs + const branchLabels = branchInfo.querySelectorAll('.pr-branch-label'); + if (branchLabels.length < 2) return; + + const sourceInput = document.createElement('input'); + sourceInput.type = 'text'; + sourceInput.value = pr.sourceLabel; + sourceInput.className = 'pr-branch-label'; + sourceInput.style.border = '1px solid rgba(255,255,255,0.2)'; + sourceInput.style.background = 'rgba(255,255,255,0.03)'; + sourceInput.style.outline = 'none'; + sourceInput.style.fontFamily = "'Courier New', Courier, monospace"; + branchLabels[0].replaceWith(sourceInput); + + const targetInput = document.createElement('input'); + targetInput.type = 'text'; + targetInput.value = pr.targetLabel; + targetInput.className = 'pr-branch-label'; + targetInput.style.border = '1px solid rgba(255,255,255,0.2)'; + targetInput.style.background = 'rgba(255,255,255,0.03)'; + targetInput.style.outline = 'none'; + targetInput.style.fontFamily = "'Courier New', Courier, monospace"; + branchLabels[1].replaceWith(targetInput); + + sourceInput.focus(); + sourceInput.select(); + + // Hide edit button + const editBtn = branchInfo.querySelector('.pr-hover-edit') as HTMLElement; + if (editBtn) editBtn.style.display = 'none'; + + const save = () => { + const newSource = sourceInput.value.trim(); + const newTarget = targetInput.value.trim(); + if (newSource) pr.sourceLabel = newSource; + if (newTarget) pr.targetLabel = newTarget; + pr.updatedAt = new Date().toISOString(); + render(); + }; + + let saving = false; + const trySave = () => { + if (saving) return; + // Only save when both inputs lose focus + setTimeout(() => { + if (document.activeElement !== sourceInput && document.activeElement !== targetInput) { + saving = true; + save(); + } + }, 0); + }; + + sourceInput.addEventListener('blur', trySave); + targetInput.addEventListener('blur', trySave); + sourceInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); save(); } + if (e.key === 'Escape') { render(); } + }); + targetInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); save(); } + if (e.key === 'Escape') { render(); } + }); +} + +// ---- Event binding ---- + +function bindEvents(): void { + if (!currentContainer || !navigateFn || !currentParams) return; + + // Filter tabs + currentContainer.querySelectorAll('[data-pr-filter]').forEach(tab => { + tab.addEventListener('click', () => { + const filter = (tab as HTMLElement).dataset.prFilter!; + currentContainer!.querySelectorAll('[data-pr-filter]').forEach(t => t.classList.remove('active')); + tab.classList.add('active'); + currentContainer!.querySelectorAll('[data-pr-filter-group]').forEach(row => { + const group = (row as HTMLElement).dataset.prFilterGroup; + (row as HTMLElement).style.display = group === filter ? '' : 'none'; + }); + }); + }); + + // PR row clicks + currentContainer.querySelectorAll('.pr-row').forEach(row => { + row.addEventListener('click', () => { + const prId = (row as HTMLElement).dataset.prId!; + navigateFn!(buildPullsUrl(currentParams!, prId)); + }); + }); + + // Link clicks (data-link) + currentContainer.querySelectorAll('[data-link]').forEach(link => { + link.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + const href = (link as HTMLAnchorElement).getAttribute('href'); + if (href && href !== '#') navigateFn!(href); + }); + }); + + // Diff mode toggle + currentContainer.querySelectorAll('[data-diff-mode]').forEach(btn => { + btn.addEventListener('click', () => { + currentDiffMode = (btn as HTMLElement).dataset.diffMode as 'unified' | 'side-by-side'; + render(); + }); + }); + + // Diff file collapse toggle + currentContainer.querySelectorAll('[data-diff-collapse]').forEach(header => { + header.addEventListener('click', () => { + const body = header.nextElementSibling as HTMLElement; + if (body) body.classList.toggle('collapsed'); + }); + }); + + // Edit buttons + currentContainer.querySelectorAll('[data-edit-action]').forEach(btn => { + btn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + const action = (btn as HTMLElement).dataset.editAction!; + if (action === 'title') startEditTitle(); + else if (action === 'description') startEditDescription(); + else if (action === 'branch') startEditBranch(); + else if (action === 'comment') { + const commentId = parseInt((btn as HTMLElement).dataset.commentId!, 10); + startEditComment(commentId); + } + }); + }); + + // Merge button + const mergeBtn = currentContainer.querySelector('[data-pr-merge]') as HTMLButtonElement | null; + if (mergeBtn && currentParams) { + mergeBtn.addEventListener('click', () => { void (async () => { + const currentUser = getCurrentPlayer(); + const pr = await getPullRequest(currentParams!.repoPath, currentParams!.prId!); + if (pr) { + pr.status = 'merged'; + pr.updatedAt = new Date().toISOString(); + pr.mergeable = false; + pr.activity.push({ type: 'merge', author: currentUser, createdAt: new Date().toISOString() }); + pr.activity.push({ type: 'status_change', from: 'open', to: 'merged', author: currentUser, createdAt: new Date().toISOString() }); + render(); + } + })(); }); + } + + // Comment submit + const commentSubmit = currentContainer.querySelector('[data-pr-comment-submit]') as HTMLButtonElement | null; + if (commentSubmit && currentParams) { + commentSubmit.addEventListener('click', () => { void (async () => { + const currentUser = getCurrentPlayer(); + const input = currentContainer!.querySelector('[data-pr-comment-input]') as HTMLTextAreaElement | null; + if (!input || !input.value.trim()) return; + const pr = await getPullRequest(currentParams!.repoPath, currentParams!.prId!); + if (pr) { + const comment = { + id: pr.comments.length + 100, + author: currentUser, + body: input.value.trim(), + createdAt: new Date().toISOString(), + }; + pr.comments.push(comment); + pr.activity.push({ type: 'comment', comment, createdAt: comment.createdAt }); + pr.updatedAt = comment.createdAt; + render(); + } + })(); }); + } + + // Create PR + const createBtn = currentContainer.querySelector('[data-pr-create]') as HTMLButtonElement | null; + if (createBtn && currentParams) { + createBtn.addEventListener('click', () => { void (async () => { + const titleInput = currentContainer!.querySelector('[data-pr-title-input]') as HTMLInputElement | null; + const descInput = currentContainer!.querySelector('[data-pr-desc-input]') as HTMLTextAreaElement | null; + const targetSelect = currentContainer!.querySelector('[data-pr-target-branch]') as HTMLSelectElement | null; + const title = titleInput?.value.trim() || ''; + const desc = descInput?.value.trim() || ''; + const target = targetSelect?.value || 'main'; + if (!title) { + if (titleInput) titleInput.style.borderBottomColor = '#f87171'; + return; + } + const pr = await createPullRequest(currentParams!.repoPath, title, desc, 'feature/new-branch', target); + navigateFn!(buildPullsUrl(currentParams!, String(pr.id))); + })(); }); + } +} + +// ---- Render dispatcher ---- + +async function render(): Promise { + if (!currentContainer || !currentParams) return; + + let html = ''; + if (currentParams.prAction === 'list') { + html = await renderPRList(currentParams); + } else if (currentParams.prAction === 'players' || currentParams.prAction === 'worlds') { + html = await renderCategoryList(currentParams); + } else if (currentParams.prAction === 'new') { + html = await renderNewPRForm(currentParams); + } else if (currentParams.prAction === 'detail') { + if (currentParams.commitId) { + html = await renderCommitDiff(currentParams); + } else { + html = await renderPRDetail(currentParams); + } + } + + currentContainer.innerHTML = html; + bindEvents(); + + // Auto-activate closed tab if URL has ?filter=closed + const filterParam = new URLSearchParams(window.location.search).get('filter'); + if (filterParam === 'closed') { + const closedTab = currentContainer.querySelector('[data-pr-filter="closed"]') as HTMLElement; + if (closedTab) closedTab.click(); + } +} + +// ---- Public API ---- + +export async function mount( + container: HTMLElement, + params: PRParams, + navigate: (path: string) => void, +): Promise { + injectStyles(); + currentContainer = container; + currentParams = params; + navigateFn = navigate; + await render(); +} + +export async function update(params: PRParams): Promise { + currentParams = params; + await render(); +} + +export function unmount(): void { + currentContainer = null; + currentParams = null; +} diff --git a/@ether/.html/UI/Repository.ts b/@ether/.html/UI/Repository.ts new file mode 100644 index 00000000..9fa754b8 --- /dev/null +++ b/@ether/.html/UI/Repository.ts @@ -0,0 +1,2807 @@ +// ============================================================ +// Repository.ts — Player page: file explorer + README rendering +// ============================================================ + +import { PHOSPHOR, CRT_SCREEN_BG } from './CRTShell.ts'; +import { renderMarkdown } from './Markdown.ts'; +import { fileIcon, accessIcon, accessSvg, fileOrEncryptedIcon } from './FileIcons.ts'; +import { getAPI, getRepository, getReferencedUsers, getReferencedWorlds, getWorld, resolveDirectory, resolveFiles, isCompound, flattenEntries, getOpenPRCount, getCurrentPlayer, getStars, toggleStar, isStarred, getStarCount, setStarCount, loadSession, saveSession, getSessionContent } from './API.ts'; +import type { FileEntry, CompoundEntry, TreeEntry, Repository } from './API.ts'; +import { createIDELayout, generateId, ensureIdCounter, injectIDEStyles } from './IDELayout.ts'; +import type { IDELayoutAPI, PanelDefinition, LayoutNode, TabGroupNode, SplitNode } from './IDELayout.ts'; + +let styleEl: HTMLStyleElement | null = null; +let currentContainer: HTMLElement | null = null; +let navigateFn: ((path: string) => void) | null = null; +let iframeCleanup: (() => void) | null = null; +let virtualScrollCleanup: (() => void) | null = null; +let ideLayoutInstance: IDELayoutAPI | null = null; +let currentFileViewEntries: TreeEntry[] | null = null; // entries used in current file-view (for hash fast path) +let currentFileViewBasePath: string | null = null; // sidebar base path for current file-view +let currentMakeFilePanel: ((panelId: string, name: string, files: FileEntry[]) => PanelDefinition) | null = null; +const sidebarExpanded = new Set(); // tracks manually expanded/collapsed dirs + +function loadSidebarExpanded(user: string): void { + const session = loadSession(user); + sidebarExpanded.clear(); + if (Array.isArray(session.sidebarExpanded)) { + for (const key of session.sidebarExpanded) sidebarExpanded.add(key); + } +} + +function saveSidebarExpanded(user: string): void { + const session = loadSession(user); + session.sidebarExpanded = [...sidebarExpanded]; + saveSession(user, session); +} + + +// ---- IDE layout session helpers ---- + +function collectPanelIds(node: LayoutNode): string[] { + if (node.type === 'tabgroup') return [...node.panels]; + const ids: string[] = []; + for (const child of node.children) ids.push(...collectPanelIds(child)); + return ids; +} + +/** Strip panels from saved layout that are not in validIds. Returns null if empty. */ +function filterLayoutPanels(node: LayoutNode, validIds: Set): LayoutNode | null { + if (node.type === 'tabgroup') { + const filtered = node.panels.filter(id => validIds.has(id)); + if (filtered.length === 0) return null; + return { ...node, panels: filtered, activeIndex: Math.min(node.activeIndex, filtered.length - 1) }; + } + const children: LayoutNode[] = []; + const sizes: number[] = []; + for (let i = 0; i < node.children.length; i++) { + const result = filterLayoutPanels(node.children[i], validIds); + if (result) { children.push(result); sizes.push(node.sizes[i]); } + } + if (children.length === 0) return null; + if (children.length === 1) return children[0]; + const sizeSum = sizes.reduce((a, b) => a + b, 0); + return { ...node, children, sizes: sizes.map(s => s / sizeSum) }; +} + +/** Find the max numeric suffix from ide-N IDs in a layout tree */ +function maxIdInLayout(node: LayoutNode): number { + let max = 0; + const m = node.id.match(/^ide-(\d+)$/); + if (m) max = Math.max(max, parseInt(m[1], 10)); + if (node.type === 'split') { + for (const child of node.children) max = Math.max(max, maxIdInLayout(child)); + } + return max; +} + +let saveTimeout: ReturnType | null = null; + +function saveIDESession(user: string, sidebarBasePath: string): void { + if (!ideLayoutInstance) return; + // Debounce to avoid thrashing localStorage during rapid resize + if (saveTimeout) clearTimeout(saveTimeout); + saveTimeout = setTimeout(() => { + if (!ideLayoutInstance) return; + const session = loadSession(user); + session.ideLayout = ideLayoutInstance.getLayout(); + session.ideLayoutBase = sidebarBasePath; + saveSession(user, session); + }, 300); +} + +// File viewer constants +const LINE_HEIGHT = 20; +const VIRTUAL_THRESHOLD = 500; +const BUFFER_LINES = 50; + +// Current state +let currentRepoParams: { user: string; path: string[]; versions: [number, string][]; base: string; hash: string | null } | null = null; + +function injectStyles(): void { + if (styleEl) return; + styleEl = document.createElement('style'); + styleEl.textContent = ` + .repo-page { + max-width: 960px; + margin: 0 auto; + padding: 32px 24px; + font-family: 'Courier New', Courier, monospace; + color: ${PHOSPHOR}; + min-height: 100vh; + box-sizing: border-box; + } + + .repo-header { + display: flex; + align-items: baseline; + gap: 8px; + font-size: 22px; + margin-bottom: 8px; + text-shadow: + 0 0 4px rgba(255,255,255,0.5), + 0 0 11px rgba(255,255,255,0.22); + } + .repo-header .user { color: rgba(255,255,255,0.55); } + .repo-header .sep { color: rgba(255,255,255,0.25); } + .repo-header .repo-name { color: ${PHOSPHOR}; font-weight: bold; } + + .repo-header a { + color: inherit; + text-decoration: none; + } + .repo-header a:hover { text-decoration: underline; } + + .repo-description { + color: rgba(255,255,255,0.4); + font-size: 14px; + margin-bottom: 24px; + } + + .repo-nav-row { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 2px; + margin-bottom: 6px; + position: relative; + } + .nav-actions { display: none; } + .breadcrumb-actions { display: contents; } + + .repo-breadcrumb { + display: flex; + align-items: center; + gap: 4px; + font-size: 14px; + margin-bottom: 16px; + color: rgba(255,255,255,0.45); + position: relative; + } + .repo-breadcrumb a, .repo-breadcrumb span, .repo-breadcrumb .version-badge { + flex-shrink: 0; + white-space: nowrap; + } + .repo-breadcrumb a { + color: rgba(255,255,255,0.65); + text-decoration: none; + cursor: pointer; + } + .repo-breadcrumb a:hover { color: ${PHOSPHOR}; text-decoration: underline; } + .repo-breadcrumb .sep { margin: 0 2px; } + + .version-badge { + display: inline-block; + font-size: 12px; + padding: 2px 8px; + border: 1px solid rgba(255,255,255,0.15); + border-radius: 10px; + color: rgba(255,255,255,0.5); + margin-left: 4px; + } + + .file-table { + width: 100%; + border: 1px solid rgba(255,255,255,0.1); + border-radius: 6px; + overflow: hidden; + margin-bottom: 32px; + } + + .file-row { + display: flex; + align-items: center; + padding: 8px 16px; + border-bottom: 1px solid rgba(255,255,255,0.06); + cursor: pointer; + transition: background 0.1s; + } + .file-row:last-child { border-bottom: none; } + .file-row:hover { background: rgba(255,255,255,0.04); } + + .file-icon { + flex: 0 0 24px; + display: flex; + align-items: center; + justify-content: center; + margin-right: 10px; + } + .file-icon svg { display: block; } + + .file-access { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + gap: 1px; + margin-right: 6px; + } + .file-access svg { display: block; } + + /* Access badge tooltip */ + .access-badge { + display: inline-flex; + align-items: center; + cursor: pointer; + } + .access-badge > svg { display: block; } + .access-tooltip { + display: none; + position: fixed; + font-family: 'Courier New', Courier, monospace; + background: #111111; + border: 1px solid rgba(255,255,255,0.12); + border-radius: 6px; + padding: 6px 10px; + white-space: nowrap; + font-size: 12px; + line-height: 1.4; + color: rgba(255,255,255,0.85); + z-index: 2147483647; + pointer-events: auto; + box-shadow: 0 4px 12px rgba(0,0,0,0.5); + gap: 6px; + align-items: baseline; + } + .access-tooltip.visible { display: flex; } + .access-tooltip-label { font-weight: 600; } + .access-tooltip-desc { color: rgba(255,255,255,0.5); } + .access-tooltip-link { + color: ${PHOSPHOR}; + text-decoration: none; + cursor: pointer; + } + .access-tooltip-link:hover { text-decoration: underline; } + .access-tooltip-input { + background: transparent; + border: none; + outline: none; + font-family: 'Courier New', Courier, monospace; + font-size: 12px; + font-weight: 600; + color: inherit; + padding: 0; + margin: 0; + width: 100%; + min-width: 80px; + } + + .file-name { + flex: 1; + font-size: 14px; + color: rgba(255,255,255,0.85); + } + .file-row:hover .file-name { color: ${PHOSPHOR}; } + + .file-modified { + font-size: 12px; + color: rgba(255,255,255,0.25); + white-space: nowrap; + } + + .readme-section { + border: 1px solid rgba(255,255,255,0.1); + border-radius: 6px; + overflow: hidden; + } + + .readme-header { + padding: 10px 16px; + font-size: 13px; + font-weight: bold; + color: rgba(255,255,255,0.6); + border-bottom: 1px solid rgba(255,255,255,0.1); + background: rgba(255,255,255,0.02); + } + + .readme-body { + padding: 24px 32px; + font-size: 14px; + line-height: 1.7; + color: rgba(255,255,255,0.8); + } + + .readme-body h1 { + font-size: 28px; + margin: 0 0 16px 0; + padding-bottom: 8px; + border-bottom: 1px solid rgba(255,255,255,0.1); + color: ${PHOSPHOR}; + text-shadow: 0 0 6px rgba(255,255,255,0.3); + } + .readme-body h2 { + font-size: 22px; + margin: 28px 0 12px 0; + padding-bottom: 6px; + border-bottom: 1px solid rgba(255,255,255,0.06); + color: ${PHOSPHOR}; + } + .readme-body h3 { + font-size: 18px; + margin: 24px 0 8px 0; + color: ${PHOSPHOR}; + } + .readme-body h4, .readme-body h5, .readme-body h6 { + font-size: 15px; + margin: 20px 0 6px 0; + color: rgba(255,255,255,0.9); + } + + .readme-body p { margin: 0 0 12px 0; } + + .readme-body a { + color: #7db8e0; + text-decoration: none; + } + .readme-body a:hover { text-decoration: underline; } + + .readme-body code { + background: rgba(255,255,255,0.08); + padding: 2px 6px; + border-radius: 3px; + font-size: 13px; + } + + .readme-body pre { + background: rgba(0,0,0,0.5); + border: 1px solid rgba(255,255,255,0.08); + border-radius: 6px; + padding: 16px; + overflow-x: auto; + margin: 0 0 16px 0; + } + .readme-body pre code { + background: none; + padding: 0; + font-size: 13px; + color: rgba(255,255,255,0.75); + } + + .readme-body blockquote { + border-left: 3px solid rgba(255,255,255,0.15); + margin: 0 0 12px 0; + padding: 4px 16px; + color: rgba(255,255,255,0.55); + } + + .readme-body ul, .readme-body ol { + margin: 0 0 12px 0; + padding-left: 24px; + } + .readme-body li { margin-bottom: 4px; } + .readme-body li.task-item { + list-style: none; + margin-left: -24px; + } + .readme-body li.task-item input { + margin-right: 6px; + accent-color: ${PHOSPHOR}; + } + + .readme-body table { + width: 100%; + border-collapse: collapse; + margin: 0 0 16px 0; + } + .readme-body th, .readme-body td { + border: 1px solid rgba(255,255,255,0.1); + padding: 6px 12px; + font-size: 13px; + } + .readme-body th { + background: rgba(255,255,255,0.04); + color: rgba(255,255,255,0.7); + } + + .readme-body hr { + border: none; + border-top: 1px solid rgba(255,255,255,0.1); + margin: 20px 0; + } + + .readme-body del { color: rgba(255,255,255,0.35); } + + .readme-body img { + max-width: 100%; + margin: 8px 0; + } + + .readme-body strong { + color: ${PHOSPHOR}; + } + .readme-body em { + color: rgba(255,255,255,0.9); + font-style: italic; + } + + .repo-404 { + text-align: center; + padding: 80px 20px; + color: rgba(255,255,255,0.4); + font-size: 18px; + } + .repo-404 .code { + font-size: 64px; + color: rgba(255,255,255,0.12); + margin-bottom: 16px; + } + + /* ---- Compound groups ---- */ + .compound-group { + border-left: 2px solid rgba(255,255,255,0.08); + } + .compound-and { border-color: rgba(255,255,255,0.12); } + .compound-or { border-color: rgba(0,200,80,0.3); } + .compound-count { + font-size: 12px; + color: rgba(255,255,255,0.35); + margin-left: 4px; + } + + /* ---- README tabs ---- */ + .readme-tabs { + display: flex; + border-bottom: 1px solid rgba(255,255,255,0.1); + background: rgba(255,255,255,0.02); + } + .readme-tab { + padding: 8px 16px; + font-size: 13px; + color: rgba(255,255,255,0.4); + cursor: pointer; + border: none; + background: none; + border-bottom: 2px solid transparent; + font-family: inherit; + } + .readme-tab:hover { color: rgba(255,255,255,0.6); } + .readme-tab.active { color: ${PHOSPHOR}; border-bottom-color: ${PHOSPHOR}; } + .readme-body.hidden { display: none; } + + /* ---- Action buttons (shared) ---- */ + .action-btn { + display: inline-flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 6px; + height: 26px; + box-sizing: border-box; + border-radius: 6px; + font-family: 'Courier New', Courier, monospace; + font-size: 12px; + padding: 0 10px; + cursor: pointer; + line-height: 1; + vertical-align: middle; + margin-left: 4px; + } + .action-btn .action-icon { + display: flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + flex-shrink: 0; + } + .action-btn .action-icon svg { + width: 14px; + height: 14px; + fill: currentColor; + display: block; + } + .action-btn .action-label { + display: flex; + align-items: center; + height: 100%; + } + .action-icon-small { display: none !important; } + .action-count { + font-weight: bold; + } + + /* ---- Star button ---- */ + .star-btn { + background: none; + border: 1px solid rgba(255,255,255,0.15); + color: rgba(255,255,255,0.55); + transition: border-color 0.15s, color 0.15s; + } + .star-btn:hover { border-color: rgba(255,255,255,0.3); color: rgba(255,255,255,0.75); } + .star-btn.starred { color: #f5a623; border-color: #f5a623; } + .star-btn.starred:hover { color: #f7b84e; border-color: #f7b84e; } + + /* ---- Icon-only buttons (PR, Settings) ---- */ + .icon-btn { + background: none; + border: none; + border-bottom: 2px solid transparent; + border-radius: 0; + color: rgba(255,255,255,0.5); + padding: 0 6px; + } + .icon-btn:hover { + color: rgba(255,255,255,0.85); + border-bottom-color: rgba(255,255,255,0.3); + } + + /* ---- Clone / Download button ---- */ + .clone-btn { + background: #00c850; + border: 1px solid #00c850; + color: #0a0a0a; + transition: background 0.15s, border-color 0.15s; + } + .clone-btn:hover { + background: #00da58; + border-color: #00da58; + } + + /* ---- Popup (reusable) ---- */ + .popup { + position: absolute; + top: calc(100% + 6px); + right: 0; + width: fit-content; + max-width: 100%; + z-index: 100; + background: #0e0e0e; + border: 1px solid rgba(255,255,255,0.12); + border-radius: 8px; + padding: 16px; + box-shadow: 0 4px 24px rgba(0,0,0,0.6), 0 0 12px rgba(255,255,255,0.03); + font-family: 'Courier New', Courier, monospace; + display: none; + } + .popup.open { display: block; } + .popup-backdrop { + position: fixed; + inset: 0; + z-index: 99; + display: none; + } + .popup-backdrop.open { display: block; } + + .popup-row { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 12px; + } + .popup-row:last-child { margin-bottom: 0; } + + .popup-row-icon { + flex-shrink: 0; + display: flex; + align-items: center; + } + .popup-row-icon img, + .popup-row-icon svg { + width: 22px; + height: 22px; + } + .popup-row-icon svg { fill: rgba(255,255,255,0.4); } + + .popup-code { + flex: 1; + min-width: 0; + background: rgba(255,255,255,0.04); + border: 1px solid rgba(255,255,255,0.08); + border-radius: 4px; + padding: 6px 10px; + font-size: 12px; + color: rgba(255,255,255,0.7); + word-break: break-all; + overflow-wrap: anywhere; + } + + .copy-btn { + flex-shrink: 0; + background: none; + border: 1px solid rgba(255,255,255,0.1); + border-radius: 4px; + padding: 5px 7px; + cursor: pointer; + color: rgba(255,255,255,0.4); + transition: color 0.15s, border-color 0.15s; + display: flex; + align-items: center; + } + .copy-btn svg { + width: 14px; + height: 14px; + fill: currentColor; + } + .copy-btn:hover { + color: ${PHOSPHOR}; + border-color: rgba(255,255,255,0.25); + } + .copy-btn.copied { + color: ${PHOSPHOR}; + border-color: ${PHOSPHOR}; + } + + /* ---- Popup ether block (clone + fork in one block) ---- */ + .popup-ether-block { + display: flex; + align-items: center; + gap: 10px; + } + .popup-ether-icon { + flex-shrink: 0; + display: flex; + align-items: center; + align-self: center; + } + .popup-ether-icon img { + width: 22px; + height: 22px; + } + .popup-ether-lines { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; + } + .popup-ether-line { + display: flex; + align-items: center; + gap: 6px; + } + .popup-play-btn { + flex-shrink: 0; + background: #00c850; + border: 1px solid #00c850; + border-radius: 4px; + padding: 5px 7px; + cursor: pointer; + color: #0a0a0a; + transition: background 0.15s, border-color 0.15s; + display: flex; + align-items: center; + } + .popup-play-btn:hover { + background: #00da58; + border-color: #00da58; + } + .popup-play-btn svg { + width: 14px; + height: 14px; + fill: currentColor; + } + .popup-fork-icon { + flex-shrink: 0; + display: flex; + align-items: center; + color: rgba(255,255,255,0.4); + } + .popup-fork-icon svg { + width: 14px; + height: 14px; + fill: currentColor; + } + .popup-fork-prefix { + color: rgba(255,255,255,0.35); + font-size: 12px; + white-space: nowrap; + } + .popup-fork-input { + flex: 1; + background: transparent; + border: none; + border-bottom: 1px solid rgba(255,255,255,0.15); + outline: none; + font-family: 'Courier New', Courier, monospace; + font-size: 12px; + color: rgba(255,255,255,0.7); + padding: 2px 4px; + min-width: 0; + } + .popup-fork-input:focus { + border-bottom-color: ${PHOSPHOR}; + color: rgba(255,255,255,0.9); + } + .popup-fork-suffix { + flex-shrink: 0; + color: rgba(255,255,255,0.35); + font-size: 12px; + white-space: nowrap; + } + + /* ---- File view mode: page extends to edges ---- */ + .repo-page.file-view-mode { + max-width: none; + padding-right: 0; + padding-bottom: 0; + display: flex; + flex-direction: column; + } + .ide-layout-mount { + flex: 1 0 0; + } + .file-view-top { + max-width: none; + padding-right: 24px; + } + + /* ---- File viewer (sidebar entries used inside IDE layout panels) ---- */ + .file-view-sidebar-entry { + display: flex; + align-items: center; + padding: 4px 8px; + font-size: 13px; + color: rgba(255,255,255,0.6); + cursor: pointer; + gap: 6px; + transition: background 0.1s; + } + .file-view-sidebar-entry:hover { background: rgba(255,255,255,0.04); color: rgba(255,255,255,0.85); } + .file-view-sidebar-entry.active { background: rgba(255,255,255,0.06); color: ${PHOSPHOR}; } + .file-view-sidebar-entry svg { flex-shrink: 0; } + .sidebar-dir-header { + display: flex; + align-items: center; + padding: 4px 8px; + font-size: 13px; + color: rgba(255,255,255,0.6); + cursor: pointer; + gap: 6px; + transition: background 0.1s; + } + .sidebar-dir-header:hover { background: rgba(255,255,255,0.04); color: rgba(255,255,255,0.85); } + .sidebar-dir-header svg { flex-shrink: 0; } + .sidebar-arrow { + flex-shrink: 0; + width: 14px; + text-align: center; + font-size: 11px; + color: rgba(255,255,255,0.3); + line-height: 1; + } + .sidebar-arrow-spacer { + flex-shrink: 0; + width: 14px; + } + .sidebar-dir-children.hidden { display: none; } + .file-view-content { + flex: 1; + min-width: 0; + } + .file-view-body.hidden { display: none; } + .file-view-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 16px; + font-size: 13px; + color: rgba(255,255,255,0.6); + border-bottom: 1px solid rgba(255,255,255,0.08); + background: ${CRT_SCREEN_BG}; + position: sticky; + top: 0; + z-index: 2; + } + .file-view-header .line-count { + font-size: 12px; + color: rgba(255,255,255,0.3); + } + .file-view-tabs { + display: flex; + border-bottom: 1px solid rgba(255,255,255,0.1); + background: ${CRT_SCREEN_BG}; + position: sticky; + top: 0; + z-index: 2; + } + .file-view-tab { + padding: 8px 16px; + font-size: 13px; + color: rgba(255,255,255,0.4); + cursor: pointer; + border: none; + background: none; + border-bottom: 2px solid transparent; + font-family: inherit; + } + .file-view-tab:hover { color: rgba(255,255,255,0.6); } + .file-view-tab.active { color: ${PHOSPHOR}; border-bottom-color: ${PHOSPHOR}; } + .file-view-scroll-container { + position: relative; + tab-size: 4; + } + .file-view-virtual-spacer { + width: 100%; + } + .file-view-lines.virtual { + position: absolute; + left: 0; + right: 0; + } + .file-line { + display: flex; + height: ${LINE_HEIGHT}px; + line-height: ${LINE_HEIGHT}px; + } + .file-line-number { + flex: 0 0 60px; + text-align: right; + padding-right: 16px; + color: rgba(255,255,255,0.2); + font-size: 13px; + font-family: 'Courier New', Courier, monospace; + user-select: none; + -webkit-user-select: none; + } + .file-line-text { + flex: 1; + white-space: pre; + font-size: 13px; + font-family: 'Courier New', Courier, monospace; + color: rgba(255,255,255,0.75); + overflow-x: hidden; + } + .file-no-content { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + color: rgba(255,255,255,0.25); + font-size: 14px; + padding: 40px; + } + .file-view-body.hidden { display: none; } + + /* ---- Iframe overlay bar ---- */ + .iframe-overlay { + position: fixed; + bottom: 0; + right: 0; + background: rgba(0,0,0,0.55); + color: rgba(255,255,255,0.65); + font-family: 'Courier New', Courier, monospace; + font-size: 13px; + padding: 6px 14px; + border-top-left-radius: 8px; + z-index: 10; + display: flex; + align-items: center; + gap: 10px; + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + } + .iframe-overlay .popup { + top: auto; + bottom: calc(100% + 6px); + right: 0; + } + .iframe-overlay .overlay-label { + color: rgba(255,255,255,0.85); + pointer-events: none; + white-space: nowrap; + } + .iframe-overlay .overlay-desc { + color: rgba(255,255,255,0.4); + font-size: 12px; + pointer-events: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + } + + @media (max-width: 640px) { + .iframe-overlay { + left: 0; + border-top-left-radius: 0; + justify-content: flex-end; + gap: 6px; + padding: 6px 10px; + } + .iframe-overlay .overlay-desc { display: none; } + .iframe-overlay .action-btn .action-label { display: none; } + .iframe-overlay .action-btn { gap: 4px; padding: 0 6px; } + + .repo-breadcrumb { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; -ms-overflow-style: none; } + .repo-breadcrumb::-webkit-scrollbar { display: none; } + .breadcrumb-actions { display: none !important; } + .nav-actions { display: contents; } + .nav-actions .action-btn .action-label { display: none; } + .nav-actions .star-btn, + .nav-actions .clone-btn { gap: 4px; padding: 0 6px; } + + .action-icon-default { display: none !important; } + .action-icon-small { display: flex !important; } + } + + @media (max-width: 400px) { + .iframe-overlay .overlay-label { font-size: 11px; } + .iframe-overlay .action-btn { margin-left: 2px; } + + .repo-breadcrumb .action-btn { margin-left: 2px; } + } + `; + document.head.appendChild(styleEl); +} + +// ---- Helpers ---- + +// Shared tooltip element on document.body — escapes all stacking contexts +let accessTooltipEl: HTMLElement | null = null; +let accessTooltipBadge: Element | null = null; +let accessTooltipEditing = false; +let accessGroupContext = ''; // e.g. "@ether" or "#genesis" — set per render + +const ACCESS_TOOLTIP_DATA: Record = { + public: { label: '@public', color: 'rgba(255,255,255,0.5)', desc: 'Visible to everyone' }, + local: { label: '@local', color: '#f87171', desc: 'Only on your local machine' }, + private: { label: '@private', color: '#fb923c', desc: 'Any machine hosting your character, includes @ether' }, + npc: { label: '@npc', color: 'rgba(255,255,255,0.5)', desc: 'Only visible to NPCs' }, + player: { label: '@player', color: 'rgba(255,255,255,0.5)', desc: 'Only visible to players' }, + everyone: { label: '', color: '#fb923c', desc: '' }, // dynamic — built in showAccessTooltip +}; + +function resolveAccessLevel(value: string): string { + const v = value.trim().toLowerCase(); + if (v === '@local' || v === 'local') return 'local'; + if (v === '@public' || v === 'public') return 'public'; + if (v === '@private' || v === 'private') return 'private'; + if (v === '@npc' || v === 'npc') return 'npc'; + if (v === '@player' || v === 'player') return 'player'; + if (v.endsWith('.@everyone') || v === '@everyone') return 'everyone'; + return 'everyone'; // arbitrary group references use group icon +} + +function accessValueForLevel(level: string): string { + switch (level) { + case 'local': return '@local'; + case 'private': return '@private'; + case 'npc': return '@npc'; + case 'player': return '@player'; + case 'everyone': return (accessGroupContext || '@public') + '.@everyone'; + default: return '@public'; + } +} + +function colorForLevel(level: string): string { + return (ACCESS_TOOLTIP_DATA[level] || ACCESS_TOOLTIP_DATA.public).color; +} + +function ensureAccessTooltip(): HTMLElement { + if (!accessTooltipEl) { + accessTooltipEl = document.createElement('div'); + accessTooltipEl.className = 'access-tooltip'; + document.body.appendChild(accessTooltipEl); + // Link clicks inside the tooltip navigate via SPA router + accessTooltipEl.addEventListener('click', (e) => { + const link = (e.target as HTMLElement).closest('[data-access-link]') as HTMLAnchorElement | null; + if (link) { + e.preventDefault(); + e.stopPropagation(); + hideAccessTooltip(); + const href = link.getAttribute('href'); + if (href && navigateFn) navigateFn(href); + } + }); + } + return accessTooltipEl; +} + +function positionTooltip(badge: Element): void { + const tip = ensureAccessTooltip(); + const rect = badge.getBoundingClientRect(); + const tipRect = tip.getBoundingClientRect(); + let top = rect.top - tipRect.height - 6; + let left = rect.right - 4; + if (left + tipRect.width > window.innerWidth - 8) { + left = rect.left - tipRect.width + 4; + } + if (left < 8) left = 8; + if (top < 8) top = rect.bottom + 6; + tip.style.top = top + 'px'; + tip.style.left = left + 'px'; +} + +function showAccessTooltipDisplay(badge: Element): void { + if (accessTooltipEditing) return; + const level = (badge as HTMLElement).dataset.access || 'public'; + const customValue = (badge as HTMLElement).dataset.accessValue; + const data = ACCESS_TOOLTIP_DATA[level] || ACCESS_TOOLTIP_DATA.public; + const tip = ensureAccessTooltip(); + let label: string; + let desc: string; + if (customValue) { + label = customValue; + desc = 'Custom access group'; + } else if (level === 'everyone') { + const ctx = accessGroupContext || '@public'; + label = ctx + '.@everyone'; + desc = 'Everyone in ' + ctx; + } else { + label = data.label; + desc = data.desc; + } + tip.innerHTML = `${label} ${desc}`; + tip.classList.add('visible'); + accessTooltipBadge = badge; + positionTooltip(badge); +} + +function showAccessTooltipEdit(badge: Element): void { + const badgeEl = badge as HTMLElement; + const level = badgeEl.dataset.access || 'public'; + const currentValue = badgeEl.dataset.accessValue || accessValueForLevel(level); + const color = colorForLevel(level); + const tip = ensureAccessTooltip(); + accessTooltipEditing = true; + accessTooltipBadge = badge; + + tip.innerHTML = ``; + tip.classList.add('visible'); + const input = tip.querySelector('input')!; + input.value = currentValue; + positionTooltip(badge); + input.focus(); + input.select(); + + function commit() { + if (!accessTooltipEditing) return; + const raw = input.value.trim(); + if (raw) { + const newLevel = resolveAccessLevel(raw); + const newColor = colorForLevel(newLevel); + badgeEl.dataset.access = newLevel; + // Store custom value for non-standard entries; clear for standard ones + const standard = accessValueForLevel(newLevel); + if (raw.toLowerCase() === standard.toLowerCase()) { + delete badgeEl.dataset.accessValue; + } else { + badgeEl.dataset.accessValue = raw; + } + // Swap SVG icon + const svgEl = badgeEl.querySelector('svg'); + if (svgEl) { + const tmp = document.createElement('span'); + tmp.innerHTML = accessSvg(newLevel); + const newSvg = tmp.querySelector('svg'); + if (newSvg) badgeEl.replaceChild(newSvg, svgEl); + } + input.style.color = newColor; + } + hideAccessTooltip(); + } + + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); commit(); } + if (e.key === 'Escape') { e.preventDefault(); hideAccessTooltip(); } + }); + input.addEventListener('blur', () => { + // Small delay so click-outside dismiss doesn't race + setTimeout(commit, 100); + }); +} + +function hideAccessTooltip(): void { + accessTooltipEditing = false; + if (accessTooltipEl) accessTooltipEl.classList.remove('visible'); + accessTooltipBadge = null; +} + +function bindAccessBadges(root: HTMLElement): void { + root.querySelectorAll('.access-badge').forEach(badge => { + // Desktop hover — display mode only + badge.addEventListener('mouseenter', () => { + if (!accessTooltipEditing) showAccessTooltipDisplay(badge); + }); + badge.addEventListener('mouseleave', () => { + if (!accessTooltipEditing) hideAccessTooltip(); + }); + // Click — enter edit mode + badge.addEventListener('click', (e) => { + e.stopPropagation(); + if (accessTooltipEditing && accessTooltipBadge === badge) return; // already editing this one + hideAccessTooltip(); + showAccessTooltipEdit(badge); + }); + }); +} + +// Dismiss tooltip on outside click (unless clicking inside the tooltip itself) +document.addEventListener('click', (e) => { + if (accessTooltipEl && accessTooltipEl.contains(e.target as Node)) return; + hideAccessTooltip(); +}); + +function bindClickHandlers(): void { + if (!currentContainer || !navigateFn) return; + + currentContainer.querySelectorAll('[data-href]').forEach(el => { + el.addEventListener('click', (e) => { + e.preventDefault(); + navigateFn!((el as HTMLElement).dataset.href!); + }); + }); + + currentContainer.querySelectorAll('[data-link]').forEach(link => { + link.addEventListener('click', (e) => { + e.preventDefault(); + navigateFn!((link as HTMLAnchorElement).getAttribute('href')!); + }); + }); + + // Access badge tooltips — tap to toggle on mobile, link clicks navigate + bindAccessBadges(currentContainer); + + // Star toggle (sync all copies) + const starBtns = currentContainer.querySelectorAll('[data-star-toggle]') as NodeListOf; + starBtns.forEach(btn => { + btn.addEventListener('click', () => { + const path = btn.dataset.starPath!; + const nowStarred = toggleStar(path); + const count = getStarCount(path) + (nowStarred ? 1 : -1); + setStarCount(path, count); + const cls = nowStarred ? 'action-btn star-btn starred' : 'action-btn star-btn'; + const inner = `${Math.max(0, count)}${nowStarred ? STAR_FILLED_SVG : STAR_OUTLINE_SVG}${nowStarred ? 'Starred' : 'Star'}`; + starBtns.forEach(b => { b.className = cls; b.innerHTML = inner; }); + }); + }); + + // Clone popup — each toggle button opens the popup within its own actions container. + // On mobile nav-actions is visible (breadcrumb-actions is hidden), so each needs its own binding. + currentContainer.querySelectorAll('[data-clone-toggle]').forEach(toggle => { + const actionsParent = toggle.closest('.nav-actions, .breadcrumb-actions, .repo-breadcrumb'); + if (!actionsParent) return; + const popup = actionsParent.querySelector('[data-clone-popup]') as HTMLElement | null; + const backdrop = actionsParent.querySelector('[data-clone-backdrop]') as HTMLElement | null; + if (!popup || !backdrop) return; + const close = () => { popup.classList.remove('open'); backdrop.classList.remove('open'); }; + toggle.addEventListener('click', (e) => { + e.stopPropagation(); + const open = popup.classList.toggle('open'); + backdrop.classList.toggle('open', open); + }); + backdrop.addEventListener('click', close); + }); + + // README tab switching + currentContainer.querySelectorAll('[data-readme-tab]').forEach(tab => { + tab.addEventListener('click', () => { + const idx = (tab as HTMLElement).dataset.readmeTab!; + // Deactivate all tabs + currentContainer!.querySelectorAll('[data-readme-tab]').forEach(t => t.classList.remove('active')); + // Hide all bodies + currentContainer!.querySelectorAll('[data-readme-body]').forEach(b => b.classList.add('hidden')); + // Activate clicked tab and show its content + tab.classList.add('active'); + const body = currentContainer!.querySelector(`[data-readme-body="${idx}"]`); + if (body) body.classList.remove('hidden'); + }); + }); + + // Sidebar directory toggle (expand/collapse) + currentContainer.querySelectorAll('[data-sidebar-toggle]').forEach(toggle => { + toggle.addEventListener('click', (e) => { + e.stopPropagation(); + e.preventDefault(); + const dirEl = toggle.closest('.sidebar-dir')!; + const children = dirEl.querySelector(':scope > .sidebar-dir-children'); + if (!children) { + // No children to toggle — navigate to the directory instead + const href = (toggle as HTMLElement).dataset.sidebarHref; + if (href && navigateFn) navigateFn(href); + return; + } + const isExpanded = !children.classList.contains('hidden'); + children.classList.toggle('hidden'); + const arrow = toggle.querySelector('.sidebar-arrow'); + if (arrow) arrow.textContent = isExpanded ? '▸' : '▾'; + const key = (toggle as HTMLElement).dataset.sidebarKey; + if (key) { + if (isExpanded) sidebarExpanded.delete(key); else sidebarExpanded.add(key); + const u = getCurrentPlayer(); + saveSidebarExpanded(u); + } + }); + }); + + // File-with-children arrow toggle (expand/collapse without navigating) + currentContainer.querySelectorAll('[data-sidebar-toggle-arrow]').forEach(arrow => { + arrow.addEventListener('click', (e) => { + e.stopPropagation(); + e.preventDefault(); + const dirEl = arrow.closest('.sidebar-dir')!; + const children = dirEl.querySelector(':scope > .sidebar-dir-children'); + if (!children) return; + const isExpanded = !children.classList.contains('hidden'); + children.classList.toggle('hidden'); + arrow.textContent = isExpanded ? '▸' : '▾'; + const key = (arrow as HTMLElement).dataset.sidebarKey; + if (key) { + if (isExpanded) sidebarExpanded.delete(key); else sidebarExpanded.add(key); + const u = getCurrentPlayer(); + saveSidebarExpanded(u); + } + }); + }); + + // File viewer tab switching + currentContainer.querySelectorAll('[data-file-tab]').forEach(tab => { + tab.addEventListener('click', () => { + const idx = (tab as HTMLElement).dataset.fileTab!; + // Deactivate all tabs + currentContainer!.querySelectorAll('[data-file-tab]').forEach(t => t.classList.remove('active')); + // Hide all bodies + currentContainer!.querySelectorAll('[data-file-body]').forEach(b => b.classList.add('hidden')); + // Activate clicked tab and show its body + tab.classList.add('active'); + const body = currentContainer!.querySelector(`[data-file-body="${idx}"]`); + if (body) body.classList.remove('hidden'); + // Re-init virtual scroll for the now-visible tab + const scrollEl = body?.querySelector('[data-virtual-scroll]') as HTMLElement | null; + if (scrollEl) { + // Parse files from the DOM won't work — we need to re-trigger initVirtualScroll + // Dispatch a scroll event to force re-render of virtual lines + scrollEl.dispatchEvent(new Event('scroll')); + } + }); + }); + + currentContainer.querySelectorAll('[data-copy]').forEach(btn => { + btn.addEventListener('click', () => { + const text = (btn as HTMLElement).dataset.copy!; + navigator.clipboard.writeText(text).then(() => { + btn.classList.add('copied'); + setTimeout(() => btn.classList.remove('copied'), 1200); + }); + }); + }); + + // PR button navigation + currentContainer.querySelectorAll('[data-pr-nav]').forEach(btn => { + btn.addEventListener('click', () => { + if (!navigateFn || !currentRepoParams) return; + const base = currentRepoParams.base || ''; + const cleanPath = currentRepoParams.path.filter(s => s !== '*' && s !== '**'); + const pathPart = cleanPath.length > 0 ? '/' + cleanPath.join('/') : ''; + navigateFn(`${base}${pathPart}/-/pulls`); + }); + }); +} + +// ---- Path Helpers ---- + +/** Build a URL path, inserting ~/version markers at the correct depths. + * Only includes markers whose depth <= target path length. */ +/** Encode a single path segment for safe use in a URL (handles #, %, ?, etc.). */ +function encodeSegment(seg: string): string { + return encodeURIComponent(seg); +} + +function buildBasePath(base: string, versions: [number, string][], path: string[]): string { + const relevant = versions.filter(([d]) => d <= path.length) + .sort((a, b) => a[0] - b[0]); + + if (relevant.length === 0) { + // Implicit versioning — bare path + return (base || '') + (path.length > 0 ? '/' + path.join('/') : ''); + } + + let result = base || ''; + let pathIdx = 0; + let verIdx = 0; + + while (pathIdx < path.length || verIdx < relevant.length) { + if (verIdx < relevant.length && relevant[verIdx][0] === pathIdx) { + result += '/~/' + relevant[verIdx][1]; + verIdx++; + } else if (pathIdx < path.length) { + result += '/' + path[pathIdx]; + pathIdx++; + } else { + break; + } + } + + return result; +} + +// ---- Display name helper ---- +// parentContext: null = normal, '@' = inside @{: String}, '~' = inside #{: String} +type ParentContext = '@' | '~' | null; + +// Segments that have special routing meaning and need escaping. +// We use '!' as an escape prefix: @annotations → !@annotations in the URL. +// This prevents Router.ts from interpreting it as a user-switch/world-switch/etc. +// (Backslash doesn't work — browsers convert \ to / in URLs. +// Percent-encoding doesn't work for - and ~ — they're unreserved per RFC 3986 +// so browsers normalize %2D→- and %7E→~ when typed in the URL bar.) +function needsPathEscaping(name: string): boolean { + if (name.length === 0) return false; + const ch = name[0]; + if (name === '@' || name === '~') return false; // navigation entries, not directories + if (ch === '@' || ch === '~') return true; // directory names starting with @/~ + if (name === '*' || name === '**' || name === '-') return true; // exact-match special + if (ch === '!') return true; // escape the escape prefix itself + return false; +} + +function escapePathSegment(name: string): string { + return needsPathEscaping(name) ? '!' + name : name; +} + +function unescapePathSegment(seg: string): string { + const stripped = (seg.length > 1 && seg[0] === '!') ? seg.slice(1) : seg; + try { return decodeURIComponent(stripped); } catch { return stripped; } +} + +function displayEntryName(name: string, parentContext: ParentContext = null): string { + if (name === '@') return '@{: String}'; + if (name === '~') return '#{: String}'; + if (parentContext === '@') return `@${name}`; + if (parentContext === '~') return `#${name}`; + return name; +} + +/** Build href for an entry. Children of @ get /@name, children of ~ get /~name (no extra separator). */ +function buildEntryHref(basePath: string, name: string, parentContext: ParentContext = null): string { + if (parentContext === '@') { + // basePath ends with /@ — strip it and append /@name + const parent = basePath.replace(/\/@$/, ''); + return parent + '/@' + encodeSegment(name); + } + if (parentContext === '~') { + const parent = basePath.replace(/\/~$/, ''); + return parent + '/~' + encodeSegment(name); + } + // Navigation entries (@, ~) stay raw in the URL — the Router recognises them as special + if (name === '@' || name === '~') { + return basePath + (basePath.endsWith('/') ? '' : '/') + name; + } + return basePath + (basePath.endsWith('/') ? '' : '/') + encodeSegment(escapePathSegment(name)); +} + +// ---- Hash-relative path helper ---- + +function computeRelativeHash(sidebarBase: string, fullHref: string): string { + const prefix = sidebarBase.endsWith('/') ? sidebarBase : sidebarBase + '/'; + if (fullHref.startsWith(prefix)) return fullHref.slice(prefix.length); + return fullHref; +} + +// ---- Repository File View ---- + +function renderFileRow(entry: FileEntry, basePath: string, compoundSize?: number, parentContext: ParentContext = null): string { + const href = entry.isDirectory + ? buildEntryHref(basePath, entry.name, parentContext) + : basePath + '#' + entry.name; // files use hash + const displayName = displayEntryName(entry.name, parentContext); + const countBadge = compoundSize ? ` (${compoundSize})` : ''; + return `
    +
    ${accessIcon(entry)}
    +
    ${fileOrEncryptedIcon(entry)}
    +
    ${displayName}${countBadge}
    +
    ${entry.modified}
    +
    `; +} + +function renderCompoundEntry(compound: CompoundEntry, basePath: string): string { + const count = flattenEntries(compound.entries).length; + if (compound.op === '|') { + // OR: show only the first entry with the count badge + const first = compound.entries[0]; + if (isCompound(first)) return renderCompoundEntry(first, basePath); + return renderFileRow(first, basePath, count); + } + const parts = compound.entries.map(entry => { + if (isCompound(entry)) return renderCompoundEntry(entry, basePath); + return renderFileRow(entry, basePath, count); + }).join(''); + return `
    ${parts}
    `; +} + +function firstLeaf(entry: TreeEntry): FileEntry | null { + if (!isCompound(entry)) return entry; + for (const child of entry.entries) { + const leaf = firstLeaf(child); + if (leaf) return leaf; + } + return null; +} + +function renderFileListing(entries: TreeEntry[], basePath: string): string { + const sortKey = (entry: TreeEntry): { isDir: boolean; name: string } => { + if (isCompound(entry)) { + const leaf = firstLeaf(entry); + return leaf ? { isDir: leaf.isDirectory, name: leaf.name } : { isDir: false, name: '' }; + } + return { isDir: entry.isDirectory, name: entry.name }; + }; + + const sorted = [...entries].sort((a, b) => { + const ka = sortKey(a); + const kb = sortKey(b); + if (ka.isDir !== kb.isDir) return ka.isDir ? -1 : 1; + return ka.name.localeCompare(kb.name); + }); + + return `
    ${sorted.map(entry => { + if (isCompound(entry)) return renderCompoundEntry(entry, basePath); + return renderFileRow(entry, basePath); + }).join('')}
    `; +} + +interface BreadcrumbItem { label: string; href: string | null; } + +const CLONE_SVG = ``; +const COPY_SVG = ``; +const GIT_SVG = ``; +const STAR_FILLED_SVG = ``; +const STAR_OUTLINE_SVG = ``; +const PR_SVG = ``; +const SETTINGS_SVG = ``; +const DOWNLOAD_SVG = ``; +const FORK_SVG = ``; +const PLAY_SVG = ``; + +async function getPrCount(canonicalPath: string): Promise { + const count = await getOpenPRCount(canonicalPath); + if (count > 0) return count; + // clonePath strips the @user/ prefix for root users — try with it + if (!canonicalPath.startsWith('@') && currentRepoParams) { + return await getOpenPRCount(`@${currentRepoParams.user}/${canonicalPath}`); + } + return 0; +} + +function renderClonePopup(canonicalPath: string): string { + const etherCmd = `ether clone ${canonicalPath}`; + const gitCmd = `git clone git@ether.orbitmines.com:${canonicalPath}`; + + const slashIdx = canonicalPath.indexOf('/'); + const forkUser = `@${getCurrentPlayer()}/`; + const forkRepoName = slashIdx >= 0 ? canonicalPath.slice(slashIdx + 1) : canonicalPath; + const forkPlaceholder = canonicalPath.startsWith('@ether') ? canonicalPath : `@ether/${forkRepoName}`; + + return ``; +} + +function renderActionButtons(canonicalPath: string, starPath: string): string { + const starred = isStarred(starPath); + const starSvg = starred ? STAR_FILLED_SVG : STAR_OUTLINE_SVG; + const starCls = starred ? 'star-btn starred' : 'star-btn'; + const starLabel = starred ? 'Starred' : 'Star'; + const starCount = getStarCount(starPath); + + return ` + + + + ${renderClonePopup(canonicalPath)}`; +} + +/** Build a URL path sliced to `end`, preserving any wildcards from the remainder of `fullPath`. */ +function buildPathPreservingWildcards(base: string, versions: [number, string][], fullPath: string[], end: number): string { + const sliced = fullPath.slice(0, end); + for (const seg of fullPath.slice(end)) { + if (seg === '*' || seg === '**') sliced.push(seg); + } + return buildBasePath(base, versions, sliced); +} + +function buildBreadcrumbItems( + treePath: string[], + headerChain: { label: string; pathEnd: number }[], + base: string, versions: [number, string][], path: string[], treePathStart: number, + repoTree?: TreeEntry[], +): { rootLink?: { label: string; href: string }; items: BreadcrumbItem[] } { + if (treePath.length === 0) return { items: [] }; + + // Check if treePath[0] is a top-level directory in the repo tree + const isTopDir = repoTree + ? flattenEntries(repoTree).some(e => e.name === treePath[0] && e.isDirectory) + : true; + + if (!isTopDir && treePath.length === 1) { + // Top-level file — use the user/world as root link, file as item + const parentEntry = headerChain.length >= 2 + ? headerChain[headerChain.length - 2] + : headerChain[headerChain.length - 1]; + const rootLabel = parentEntry?.label || ''; + const rootHref = buildPathPreservingWildcards(base, versions, path, treePathStart); + return { + rootLink: { label: rootLabel, href: rootHref }, + items: [{ label: treePath[0], href: null }], + }; + } + + const rootLabel = treePath[0] || headerChain[headerChain.length - 1]?.label || ''; + const rootHref = buildPathPreservingWildcards(base, versions, path, treePathStart + 1); + const subPath = treePath.slice(1); + const items: BreadcrumbItem[] = subPath.map((seg, i) => ({ + label: seg, + href: i < subPath.length - 1 + ? buildPathPreservingWildcards(base, versions, path, treePathStart + 1 + i + 1) + : null, + })); + return { rootLink: { label: rootLabel, href: rootHref }, items }; +} + +async function renderBreadcrumb(displayVersion: string, items: BreadcrumbItem[], canonicalPath?: string, starPath?: string, rootLink?: { label: string; href: string }): Promise { + let html = ''; + const actionHtml = canonicalPath ? renderActionButtons(canonicalPath, starPath || canonicalPath) : ''; + if (canonicalPath) { + const prCount = await getPrCount(canonicalPath); + html += `
    + ${actionHtml} + + +
    `; + } + html += `
    `; + if (rootLink) { + html += `${rootLink.label}`; + } + html += `${displayVersion}`; + + for (const item of items) { + html += `/`; + if (item.href) { + html += `${item.label}`; + } else { + html += `${item.label}`; + } + } + + if (canonicalPath) { + html += `${actionHtml}`; + } + + html += `
    `; + return html; +} + +function renderHeaderChain( + chain: { label: string; pathEnd: number }[], + base: string, versions: [number, string][], path: string[], +): string { + let html = `
    `; + chain.forEach((item, idx) => { + if (idx > 0) html += `/`; + const isLast = idx === chain.length - 1; + const cls = isLast ? 'repo-name' : 'user'; + if (item.pathEnd >= 0) { + const href = buildPathPreservingWildcards(base, versions, path, item.pathEnd) || '/'; + html += `${item.label}`; + } else { + html += `${item.label}`; + } + }); + html += `
    `; + return html; +} + +/** Compute the star path: the first top-level directory (library) if treePath[0] is one, otherwise the user/world root. */ +function buildRootStarPath( + repository: { tree: TreeEntry[] }, + effectiveUser: string, effectiveWorld: string | null, treePath: string[], + user: string, base: string, +): string { + // If treePath[0] is a top-level directory in the repo tree, star that library + if (treePath.length > 0) { + const flat = flattenEntries(repository.tree); + const isTopDir = flat.some(e => e.name === treePath[0] && e.isDirectory); + if (isTopDir) { + let p = buildCanonicalPath(effectiveUser, effectiveWorld, treePath.slice(0, 1)); + if (!base) { + const prefix = `@${user}/`; + if (p.startsWith(prefix)) p = p.slice(prefix.length); + } + return p; + } + } + // Otherwise star the user/world root + let p = buildCanonicalPath(effectiveUser, effectiveWorld, []); + if (!base) { + const prefix = `@${user}/`; + if (p.startsWith(prefix)) p = p.slice(prefix.length); + } + return p; +} + +function buildCanonicalPath(user: string, world: string | null, treePath: string[]): string { + let p = `@${user}`; + if (world) p += `/#${world}`; + if (treePath.length > 0) p += '/' + treePath.join('/'); + return p; +} + +function mountIframe(container: HTMLElement, jsContent: string, canonicalPath: string): HTMLIFrameElement { + const iframe = document.createElement('iframe'); + iframe.sandbox.add('allow-scripts'); + iframe.src = '/sandbox.html'; + iframe.style.cssText = 'width: 100%; border: none; border-radius: 6px; background: #0a0a0a; min-height: 300px; flex-grow: 1;'; + + let iframeReady = false; + + const sendInit = (includeScript: boolean) => { + if (!iframe.contentWindow) return; + iframe.contentWindow.postMessage({ + type: 'ether:init', + user: getCurrentPlayer(), + repo: canonicalPath, + ...(includeScript ? { script: jsContent } : {}), + }, '*'); + }; + + const onMessage = (e: MessageEvent) => { + if (e.source !== iframe.contentWindow) return; + const data = e.data; + if (!data || !data.type) return; + + if (data.type === 'ether:ready') { + iframeReady = true; + sendInit(true); + } else if (data.type === 'ether:storage') { + const nsKey = `ray:${canonicalPath}:${data.key}`; + let value: string | null = null; + if (data.action === 'get') { + value = localStorage.getItem(nsKey); + } else if (data.action === 'set') { + localStorage.setItem(nsKey, data.value); + } else if (data.action === 'remove') { + localStorage.removeItem(nsKey); + } + iframe.contentWindow!.postMessage({ + type: 'ether:storage:response', + id: data.id, + value: value, + }, '*'); + } + }; + + const onCharacter = () => { + if (iframeReady) sendInit(false); + }; + + window.addEventListener('message', onMessage); + window.addEventListener('ether:character', onCharacter); + container.appendChild(iframe); + + iframeCleanup = () => { + window.removeEventListener('message', onMessage); + window.removeEventListener('ether:character', onCharacter); + if (iframe.parentNode) iframe.parentNode.removeChild(iframe); + }; + + return iframe; +} + +function findReadmes(entries: TreeEntry[]): FileEntry[] { + const result: FileEntry[] = []; + for (const entry of entries) { + if (isCompound(entry)) { + result.push(...findReadmes(entry.entries)); + } else if (entry.name === 'README.md' && !entry.isDirectory) { + result.push(entry); + } + } + return result; +} + +// ---- File Viewer ---- + +function escapeHtml(text: string): string { + return text.replace(/&/g, '&').replace(//g, '>'); +} + +function renderLine(lineNum: number, text: string): string { + const display = text === '' ? ' ' : escapeHtml(text); + return `
    ${lineNum}${display}
    `; +} + +function renderFileViewContent(files: FileEntry[]): string { + let html = ''; + + // Tabs if multiple files (superposed) + if (files.length > 1) { + html += `
    `; + files.forEach((f, i) => { + const label = files.every(ff => ff.name === files[0].name) + ? `${f.name} (${i + 1})` + : f.name; + const active = i === 0 ? ' active' : ''; + html += ``; + }); + html += `
    `; + } + + // Content pane for each file + files.forEach((file, i) => { + const hidden = i === 0 ? '' : ' hidden'; + html += `
    `; + + if (!file.content) { + html += `
    ${escapeHtml(file.name)}
    `; + html += `
    No content available
    `; + } else { + // Count lines cheaply without splitting the whole string + let lineCount = 1; + for (let ci = 0; ci < file.content.length; ci++) { + if (file.content.charCodeAt(ci) === 10) lineCount++; + } + html += `
    ${escapeHtml(file.name)}${lineCount} lines
    `; + + if (lineCount <= VIRTUAL_THRESHOLD) { + // Small file — split and render all lines directly + const lines = file.content.split('\n'); + html += `
    `; + html += `
    `; + lines.forEach((line, li) => { + html += renderLine(li + 1, line); + }); + html += `
    `; + } else { + // Large file — virtual scroll (content processed incrementally in initVirtualScroll) + const totalHeight = lineCount * LINE_HEIGHT; + html += `
    `; + html += `
    `; + html += `
    `; + html += `
    `; + } + } + + html += `
    `; + }); + + return html; +} + +function renderSidebarTree(entries: TreeEntry[], basePath: string, expandPath: string[], depth: number, parentContext: ParentContext = null): string { + const flat = flattenEntries(entries); + const sorted = [...flat].sort((a, b) => { + if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1; + return a.name.localeCompare(b.name); + }); + const seen = new Set(); + const deduped = sorted.filter(e => { + if (seen.has(e.name)) return false; + seen.add(e.name); + return true; + }); + + const pad = 8 + depth * 16; + let html = ''; + for (const entry of deduped) { + const href = buildEntryHref(basePath, entry.name, parentContext); + const name = escapeHtml(displayEntryName(entry.name, parentContext)); + // Determine context for children of this entry + const childContext: ParentContext = entry.name === '@' ? '@' : entry.name === '~' ? '~' : null; + if (entry.isDirectory) { + const isOnPath = expandPath.length > 0 && expandPath[0] === entry.name; + if (isOnPath) sidebarExpanded.add(href); + const isExpanded = sidebarExpanded.has(href); + const children = entry.children || []; + html += ``; + } else { + const isOnPath = expandPath.length > 0 && expandPath[0] === entry.name; + const isActive = isOnPath && expandPath.length === 1; + const fileChildren = entry.children || []; + if (fileChildren.length > 0) { + // File with children — expandable like a directory, but navigates as a file + if (isOnPath) sidebarExpanded.add(href); + const isExpanded = sidebarExpanded.has(href); + html += ``; + } else { + html += `
    `; + html += ``; + html += accessIcon(entry); + html += fileOrEncryptedIcon(entry); + html += `${name}`; + html += `
    `; + } + } + } + return html; +} + +function initVirtualScroll(container: HTMLElement, files: FileEntry[]): void { + if (virtualScrollCleanup) { virtualScrollCleanup(); virtualScrollCleanup = null; } + + const scrollContainers = container.querySelectorAll('[data-virtual-scroll]'); + if (scrollContainers.length === 0) return; + + const cleanups: (() => void)[] = []; + + scrollContainers.forEach(scrollEl => { + const lineCount = parseInt(scrollEl.dataset.lineCount || '0', 10); + const fileIdx = parseInt(scrollEl.dataset.fileIndex || '0', 10); + const file = files[fileIdx]; + if (!file || !file.content) return; + + const content = file.content; + const linesContainer = scrollEl.querySelector('.file-view-lines') as HTMLElement; + if (!linesContainer) return; + + // Line offset index: offsets[i] = char index where line i starts. + // Built incrementally — first batch sync, rest in RAF chunks. + const offsets: number[] = [0]; + let indexPos = 0; + let indexComplete = false; + let cancelled = false; + + // Index enough lines for the initial viewport + buffer synchronously + const INITIAL_INDEX = BUFFER_LINES * 2 + 100; + for (let n = 0; n < INITIAL_INDEX && indexPos < content.length; n++) { + const nl = content.indexOf('\n', indexPos); + if (nl === -1) { indexPos = content.length; break; } + offsets.push(nl + 1); + indexPos = nl + 1; + } + + if (indexPos >= content.length) { + indexComplete = true; + } else { + // Continue building the index in background RAF chunks (~8ms budget each) + const buildChunk = () => { + if (cancelled) return; + const deadline = performance.now() + 8; + while (indexPos < content.length && performance.now() < deadline) { + const nl = content.indexOf('\n', indexPos); + if (nl === -1) { indexPos = content.length; break; } + offsets.push(nl + 1); + indexPos = nl + 1; + } + if (indexPos >= content.length) { + indexComplete = true; + } else { + requestAnimationFrame(buildChunk); + } + }; + requestAnimationFrame(buildChunk); + } + + function getLine(i: number): string { + if (i < 0 || i >= offsets.length) return ''; + const start = offsets[i]; + const end = i + 1 < offsets.length ? offsets[i + 1] - 1 : content.length; + return content.substring(start, end); + } + + let ticking = false; + let lastScrollParent: HTMLElement | null = null; + + // Find nearest scrollable ancestor (overflow-y: auto/scroll) + function findScrollParent(el: HTMLElement): HTMLElement | null { + let p = el.parentElement; + while (p && p !== document.documentElement) { + const { overflowY } = getComputedStyle(p); + if (overflowY === 'auto' || overflowY === 'scroll') return p; + p = p.parentElement; + } + return null; + } + + const updateVisibleLines = () => { + const rect = scrollEl.getBoundingClientRect(); + // Detect scroll context: if inside an overflow-y container, use its bounds + const sp = findScrollParent(scrollEl); + + // Re-attach direct scroll listener if scroll parent changed (e.g. after rearrangement) + if (sp !== lastScrollParent) { + if (lastScrollParent) lastScrollParent.removeEventListener('scroll', scheduleUpdate); + if (sp) sp.addEventListener('scroll', scheduleUpdate, { passive: true }); + lastScrollParent = sp; + } + + let scrollTop: number; + let viewHeight: number; + if (sp) { + const spRect = sp.getBoundingClientRect(); + scrollTop = Math.max(0, spRect.top - rect.top); + viewHeight = sp.clientHeight; + } else { + scrollTop = Math.max(0, -rect.top); + viewHeight = window.innerHeight; + } + + const startLine = Math.max(0, Math.floor(scrollTop / LINE_HEIGHT) - BUFFER_LINES); + const maxLine = indexComplete ? lineCount : offsets.length; + const endLine = Math.min(maxLine, Math.ceil((scrollTop + viewHeight) / LINE_HEIGHT) + BUFFER_LINES); + + let html = ''; + for (let i = startLine; i < endLine; i++) { + html += renderLine(i + 1, getLine(i)); + } + + linesContainer.style.top = `${startLine * LINE_HEIGHT}px`; + linesContainer.innerHTML = html; + }; + + const scheduleUpdate = () => { + if (!ticking) { + ticking = true; + requestAnimationFrame(() => { + updateVisibleLines(); + ticking = false; + }); + } + }; + + // Listen to page scroll (always needed for last/unconstrained panels) + window.addEventListener('scroll', scheduleUpdate, { passive: true }); + // Also attach to current scroll parent if inside an overflow container + const initialSP = findScrollParent(scrollEl); + if (initialSP) { + initialSP.addEventListener('scroll', scheduleUpdate, { passive: true }); + lastScrollParent = initialSP; + } + // Render the initial viewport immediately + updateVisibleLines(); + + cleanups.push(() => { + cancelled = true; + window.removeEventListener('scroll', scheduleUpdate); + if (lastScrollParent) lastScrollParent.removeEventListener('scroll', scheduleUpdate); + }); + }); + + virtualScrollCleanup = () => { + cleanups.forEach(fn => fn()); + }; +} + +async function renderRepo(): Promise { + if (saveTimeout) { clearTimeout(saveTimeout); saveTimeout = null; } + if (ideLayoutInstance) { ideLayoutInstance.unmount(); ideLayoutInstance = null; } + currentFileViewEntries = null; + currentFileViewBasePath = null; + currentMakeFilePanel = null; + if (iframeCleanup) { iframeCleanup(); iframeCleanup = null; } + if (virtualScrollCleanup) { virtualScrollCleanup(); virtualScrollCleanup = null; } + if (!currentContainer || !currentRepoParams) return; + const { user, path, versions, base, hash } = currentRepoParams; + + // ---- Process @/~ references to resolve effective user/world and tree path ---- + let effectiveUser = user; + let effectiveWorld: string | null = null; + let effectiveWorldParent = user; // resolved parent key for getWorld lookup + let worldParentKey = user; // tracks current context for next nested world + let treePath: string[] = []; + let treePathStart = 0; + let userPathEnd = 0; + let showUsersListing = false; + let showWorldsListing = false; + let hasWildcard = false; + + // Header chain: collect context switches for title breadcrumb + // Hide implicit @user when the path immediately enters a world (~name or ~) + const firstNonWild = path.find(s => s !== '*' && s !== '**'); + const startsWithWorld = firstNonWild !== undefined && (firstNonWild === '~' || firstNonWild.startsWith('~')); + const headerChain: { label: string; pathEnd: number }[] = + (base || !startsWithWorld) ? [{ label: `@${user}`, pathEnd: 0 }] : []; + + for (let i = 0; i < path.length; i++) { + const seg = path[i]; + if (seg === '*' || seg === '**') { + // Wildcard — kept in path for URL generation, skip for tree resolution + hasWildcard = true; + if (treePath.length === 0) { + treePathStart = i + 1; + userPathEnd = i + 1; + } + continue; + } else if (seg === '@') { + if (i === path.length - 1) { + showUsersListing = true; + } else { + effectiveUser = path[i + 1]; + effectiveWorld = null; + worldParentKey = effectiveUser; + treePath = []; + treePathStart = i + 2; + userPathEnd = i + 2; + headerChain.push({ label: `@${effectiveUser}`, pathEnd: i + 2 }); + i++; // skip user name segment + } + } else if (seg.startsWith('@')) { + effectiveUser = seg.slice(1); + effectiveWorld = null; + worldParentKey = effectiveUser; + treePath = []; + treePathStart = i + 1; + userPathEnd = i + 1; + headerChain.push({ label: `@${effectiveUser}`, pathEnd: i + 1 }); + } else if (seg === '~') { + if (i === path.length - 1) { + showWorldsListing = true; + } + } else if (seg.startsWith('~')) { + const parentKey = worldParentKey; // save before updating + effectiveWorld = seg.slice(1); + treePath = []; + treePathStart = i + 1; + headerChain.push({ label: `#${effectiveWorld}`, pathEnd: i + 1 }); + worldParentKey = effectiveWorld; // nested worlds look up under this one + effectiveWorldParent = parentKey; // for resolving this world + } else { + treePath.push(unescapePathSegment(seg)); + } + } + + // Add project or listing label to the header chain + if (showUsersListing) { + headerChain.push({ label: '@{: String}', pathEnd: -1 }); + } else if (showWorldsListing) { + headerChain.push({ label: '#{: String}', pathEnd: -1 }); + } else if (treePath.length > 0) { + headerChain.push({ label: treePath[0], pathEnd: treePathStart + 1 }); + } + + // ---- Resolve data ---- + const currentPlayer = getCurrentPlayer(); + loadSidebarExpanded(currentPlayer); + let repository = effectiveWorld + ? await getWorld(effectiveWorldParent, effectiveWorld) + : await getRepository(effectiveUser); + // Virtual repository for the current player if they don't have one yet + if (!repository && !effectiveWorld && effectiveUser === currentPlayer) { + repository = { user: currentPlayer, description: `@${currentPlayer}`, tree: [] }; + } + accessGroupContext = effectiveWorld ? '#' + effectiveWorld : '@' + effectiveUser; + + if (!repository) { + const target = effectiveWorld + ? `#${effectiveWorld} in @${effectiveUser}` + : `@${effectiveUser}`; + currentContainer.innerHTML = `
    +
    +
    404
    + ${target} not found +
    +
    `; + return; + } + + let entries: TreeEntry[]; + + if (showUsersListing) { + // Show referenced users as directory entries + const referencedUsers = await getReferencedUsers(effectiveUser, effectiveWorld); + // Inject current player if not already listed + const users = referencedUsers.includes(currentPlayer) + ? referencedUsers + : [currentPlayer, ...referencedUsers]; + entries = users.map(u => ({ + name: u, isDirectory: true, modified: '', + })); + } else if (showWorldsListing) { + // Show referenced worlds as directory entries + entries = (await getReferencedWorlds(effectiveUser, effectiveWorld)).map(w => ({ + name: w, isDirectory: true, modified: '', + })); + } else { + let resolved = treePath.length > 0 + ? resolveDirectory(repository.tree, treePath) + : repository.tree; + + // If local tree resolution failed, try fetching the subdirectory from the API + if (!resolved && treePath.length > 0) { + const apiPath = effectiveWorld + ? `@${effectiveWorldParent}/~${effectiveWorld}/${treePath.join('/')}` + : `@${effectiveUser}/${treePath.join('/')}`; + const fetched = await getAPI().listDirectory(apiPath); + if (fetched.length > 0) resolved = fetched; + } + + if (!resolved) { + if (!hash && treePath.length > 0 && navigateFn) { + // Backward-compat: redirect file-in-pathname to hash-based URL + // Augment tree root with virtual entries for resolution + let resolveTree: TreeEntry[] = repository.tree; + const virtuals: TreeEntry[] = []; + const refUsers = await getReferencedUsers(effectiveUser, effectiveWorld); + if (refUsers.length > 0) { + const userChildren: FileEntry[] = await Promise.all( + (refUsers.includes(currentPlayer) ? refUsers : [currentPlayer, ...refUsers]) + .map(async u => { + const repo = await getRepository(u); + return { name: u, isDirectory: true, modified: '', children: repo ? [...repo.tree] : [] } as FileEntry; + }) + ); + virtuals.push({ name: '@', isDirectory: true, modified: '', children: userChildren } as FileEntry); + } + const refWorlds = await getReferencedWorlds(effectiveUser, effectiveWorld); + if (refWorlds.length > 0) { + const worldChildren: FileEntry[] = await Promise.all(refWorlds.map(async w => { + const worldRepo = await getWorld(worldParentKey, w); + return { name: w, isDirectory: true, modified: '', children: worldRepo ? [...worldRepo.tree] : [] } as FileEntry; + })); + virtuals.push({ name: '~', isDirectory: true, modified: '', children: worldChildren } as FileEntry); + } + if (effectiveUser === currentPlayer && !effectiveWorld) { + const stars = getStars(); + virtuals.push({ name: '.stars.list.ray', isDirectory: false, modified: '', content: stars.length > 0 ? stars.join('\n') : '' } as FileEntry); + virtuals.push({ name: 'Session.ray.json', isDirectory: false, modified: '', content: getSessionContent(currentPlayer) } as FileEntry); + } + if (virtuals.length > 0) resolveTree = [...virtuals, ...repository.tree]; + + const files = resolveFiles(resolveTree, treePath); + if (files.length > 0) { + // Find deepest directory prefix in treePath + let dirDepth = treePath.length - 1; + while (dirDepth > 0 && !resolveDirectory(resolveTree, treePath.slice(0, dirDepth))) { + dirDepth--; + } + const dirPart = treePath.slice(0, dirDepth); + const filePart = treePath.slice(dirDepth); + const parentUrl = buildBasePath(base, versions, [...path.slice(0, treePathStart), ...dirPart]); + navigateFn(parentUrl + '#' + filePart.join('/')); + return; + } + } + // 404 — directory not found + currentContainer.innerHTML = `
    +
    +
    404
    + Path not found +
    +
    `; + return; + } + + entries = resolved; + + // At tree root, inject virtual @/~ entries if there are referenced users/worlds + if (treePath.length === 0) { + const virtuals: FileEntry[] = []; + const refUsers = await getReferencedUsers(effectiveUser, effectiveWorld); + if (refUsers.length > 0) { + const userChildren: FileEntry[] = await Promise.all( + (refUsers.includes(currentPlayer) ? refUsers : [currentPlayer, ...refUsers]) + .map(async u => { + const repo = await getRepository(u); + return { name: u, isDirectory: true, modified: '', children: repo ? [...repo.tree] : [] } as FileEntry; + }) + ); + virtuals.push({ name: '@', isDirectory: true, modified: '', children: userChildren }); + } + const refWorlds = await getReferencedWorlds(effectiveUser, effectiveWorld); + if (refWorlds.length > 0) { + const worldChildren: FileEntry[] = await Promise.all(refWorlds.map(async w => { + const worldRepo = await getWorld(worldParentKey, w); + return { name: w, isDirectory: true, modified: '', children: worldRepo ? [...worldRepo.tree] : [] } as FileEntry; + })); + virtuals.push({ name: '~', isDirectory: true, modified: '', children: worldChildren }); + } + // Inject .stars.list.ray and Session.ray.json for the current player + if (effectiveUser === currentPlayer && !effectiveWorld) { + const stars = getStars(); + const starsContent = stars.length > 0 ? stars.join('\n') : ''; + virtuals.push({ name: '.stars.list.ray', isDirectory: false, modified: '', content: starsContent }); + virtuals.push({ name: 'Session.ray.json', isDirectory: false, modified: '', content: getSessionContent(currentPlayer) }); + } + const virtualNames = new Set(virtuals.map(v => v.name)); + entries = [...virtuals, ...entries.filter(e => !virtualNames.has(e.name))]; + } + } + + // ---- Hash-based file view ---- + if (hash) { + const hashPath = hash.split('/').filter(Boolean); + if (hashPath.length > 0) { + let files = resolveFiles(entries, hashPath); + // If local tree has no content (HttpBackend flat listing) or resolution failed, fetch from API + const needsFetch = files.length === 0 || files.every(f => f.content === undefined); + if (needsFetch) { + const apiFilePath = effectiveWorld + ? `@${effectiveWorldParent}/~${effectiveWorld}/${treePath.length > 0 ? treePath.join('/') + '/' : ''}${hashPath.join('/')}` + : `@${effectiveUser}/${treePath.length > 0 ? treePath.join('/') + '/' : ''}${hashPath.join('/')}`; + const content = await getAPI().readFile(apiFilePath); + if (content !== null) { + const name = hashPath[hashPath.length - 1]; + files = [{ name, isDirectory: false, modified: '', content }]; + } + } + const basePath = buildBasePath(base, versions, path); + const displayVersion = versions.length > 0 ? versions[versions.length - 1][1] : 'latest'; + + let clonePath = buildCanonicalPath(effectiveUser, effectiveWorld, [...treePath, ...hashPath]); + if (!base) { + const prefix = `@${user}/`; + if (clonePath.startsWith(prefix)) clonePath = clonePath.slice(prefix.length); + } + + const sidebarEntries = entries; + const sidebarBasePath = basePath; + const sidebarExpandPath = hashPath; + + let html = `
    `; + html += `
    `; + html += renderHeaderChain(headerChain, base, versions, path); + html += `
    ${repository.description}
    `; + + const { rootLink, items: breadcrumbItems } = buildBreadcrumbItems(treePath, headerChain, base, versions, path, treePathStart, repository.tree); + const rootStarPath = buildRootStarPath(repository, effectiveUser, effectiveWorld, treePath, user, base); + html += await renderBreadcrumb(displayVersion, breadcrumbItems, clonePath, rootStarPath, rootLink); + html += `
    `; + + html += `
    `; + html += `
    `; + currentContainer.innerHTML = html; + bindClickHandlers(); + + // Mount IDE layout into the placeholder + const layoutMount = currentContainer.querySelector('.ide-layout-mount') as HTMLElement; + if (layoutMount) { + const FOLDER_SVG = fileIcon('folder', true); + const fileName = hashPath[hashPath.length - 1] || ''; + + // Helper: create a file panel definition from resolved files + function makeFilePanel(panelId: string, name: string, panelFiles: FileEntry[]): PanelDefinition { + return { + id: panelId, + title: name || '404', + icon: panelFiles.length > 0 + ? accessIcon(panelFiles[0]) + fileOrEncryptedIcon({ name, isDirectory: false, encrypted: panelFiles[0].encrypted }) + : '', + closable: true, + render: (el) => { + if (panelFiles.length > 0) { + el.innerHTML = `
    ${renderFileViewContent(panelFiles)}
    `; + el.querySelectorAll('[data-file-tab]').forEach(tab => { + tab.addEventListener('click', () => { + const idx = (tab as HTMLElement).dataset.fileTab!; + el.querySelectorAll('[data-file-tab]').forEach(t => t.classList.remove('active')); + el.querySelectorAll('[data-file-body]').forEach(b => b.classList.add('hidden')); + tab.classList.add('active'); + const body = el.querySelector(`[data-file-body="${idx}"]`); + if (body) body.classList.remove('hidden'); + const scrollEl = body?.querySelector('[data-virtual-scroll]') as HTMLElement | null; + if (scrollEl) scrollEl.dispatchEvent(new Event('scroll')); + }); + }); + initVirtualScroll(el, panelFiles); + return () => { + if (virtualScrollCleanup) { virtualScrollCleanup(); virtualScrollCleanup = null; } + }; + } else { + el.innerHTML = `
    +
    ${escapeHtml(name)}
    +
    404
    Path not found
    +
    `; + } + }, + }; + } + + // Bind sidebar tree expand/collapse handlers + function bindSidebarTreeHandlers(el: HTMLElement): void { + el.querySelectorAll('[data-sidebar-toggle]').forEach(toggle => { + toggle.addEventListener('click', (e) => { + e.stopPropagation(); + e.preventDefault(); + const dirEl = toggle.closest('.sidebar-dir')!; + let children = dirEl.querySelector(':scope > .sidebar-dir-children'); + if (!children) { + // No children rendered yet — fetch from API and inject + const href = (toggle as HTMLElement).dataset.sidebarHref; + if (!href) return; + const prefix = sidebarBasePath.endsWith('/') ? sidebarBasePath : sidebarBasePath + '/'; + const relPathEncoded = href.startsWith(prefix) + ? href.slice(prefix.length) + : null; + if (relPathEncoded === null) { + if (navigateFn) navigateFn(href); + return; + } + // Decode relPath — href segments are URL-encoded, API paths should be decoded + const relPath = relPathEncoded.split('/').map(s => { try { return decodeURIComponent(s); } catch { return s; } }).join('/'); + // Build API path from the sidebar href + const apiSubPath = effectiveWorld + ? `@${effectiveWorldParent}/~${effectiveWorld}/${treePath.length > 0 ? treePath.join('/') + '/' : ''}${relPath}` + : `@${effectiveUser}/${treePath.length > 0 ? treePath.join('/') + '/' : ''}${relPath}`; + void (async () => { + const fetched = await getAPI().listDirectory(apiSubPath); + // Create the children container (empty or populated) + const childrenDiv = document.createElement('div'); + childrenDiv.className = 'sidebar-dir-children'; + const depth = parseInt((toggle as HTMLElement).style.paddingLeft || '8', 10); + const childDepth = Math.round((depth - 8) / 16) + 1; + childrenDiv.innerHTML = fetched.length > 0 + ? renderSidebarTree(fetched, href, [], childDepth) + : ''; + dirEl.appendChild(childrenDiv); + // Update arrow — remove caret if empty + const arrow = toggle.querySelector('.sidebar-arrow'); + if (fetched.length === 0) { + if (arrow) arrow.textContent = ''; + } else { + if (arrow) arrow.textContent = '▾'; + } + const key = (toggle as HTMLElement).dataset.sidebarKey; + if (key) { + sidebarExpanded.add(key); + saveSidebarExpanded(getCurrentPlayer()); + } + // Bind handlers on the new children + bindSidebarTreeHandlers(childrenDiv); + bindSidebarFileHandlers(childrenDiv); + bindAccessBadges(childrenDiv); + })(); + return; + } + const isExpanded = !children.classList.contains('hidden'); + children.classList.toggle('hidden'); + const arrow = toggle.querySelector('.sidebar-arrow'); + if (arrow) arrow.textContent = isExpanded ? '▸' : '▾'; + const key = (toggle as HTMLElement).dataset.sidebarKey; + if (key) { + if (isExpanded) sidebarExpanded.delete(key); else sidebarExpanded.add(key); + const u = getCurrentPlayer(); + saveSidebarExpanded(u); + } + }); + }); + el.querySelectorAll('[data-sidebar-toggle-arrow]').forEach(arrow => { + arrow.addEventListener('click', (e) => { + e.stopPropagation(); + e.preventDefault(); + const dirEl = arrow.closest('.sidebar-dir')!; + const children = dirEl.querySelector(':scope > .sidebar-dir-children'); + if (!children) return; + const isExpanded = !children.classList.contains('hidden'); + children.classList.toggle('hidden'); + arrow.textContent = isExpanded ? '▸' : '▾'; + const key = (arrow as HTMLElement).dataset.sidebarKey; + if (key) { + if (isExpanded) sidebarExpanded.delete(key); else sidebarExpanded.add(key); + const u = getCurrentPlayer(); + saveSidebarExpanded(u); + } + }); + }); + } + + // File clicks → open as new tab (or fetch file content from API) + function bindSidebarFileHandlers(el: HTMLElement): void { + el.querySelectorAll('[data-href]').forEach(entry => { + entry.addEventListener('click', (e) => { + e.preventDefault(); + const href = (entry as HTMLElement).dataset.href!; + if (ideLayoutInstance) { + const prefix = sidebarBasePath.endsWith('/') ? sidebarBasePath : sidebarBasePath + '/'; + const relPath = href.startsWith(prefix) + ? href.slice(prefix.length).split('/').filter(Boolean).map(s => { try { return decodeURIComponent(s); } catch { return s; } }).map(unescapePathSegment) + : null; + if (relPath && relPath.length > 0) { + // Decompose @name/~name segments into ['@','name'] / ['~','name'] + // so resolveFiles can traverse the virtual @ and ~ tree entries + const fileTreePath: string[] = []; + for (const seg of relPath) { + if (seg.length > 1 && (seg.startsWith('@') || seg.startsWith('~'))) { + fileTreePath.push(seg[0], seg.slice(1)); + } else { + fileTreePath.push(seg); + } + } + const resolved = resolveFiles(sidebarEntries, fileTreePath); + if (resolved.length > 0 && resolved.some(f => f.content !== undefined)) { + const relHash = relPath.map(encodeURIComponent).join('/'); + const panelId = 'file:' + relHash; + const name = fileTreePath[fileTreePath.length - 1]; + history.replaceState(null, '', location.pathname + '#' + relHash); + ideLayoutInstance.openPanel(makeFilePanel(panelId, name, resolved)); + return; + } + // File not in local tree or has no content — fetch from API + const apiFilePath = effectiveWorld + ? `@${effectiveWorldParent}/~${effectiveWorld}/${treePath.length > 0 ? treePath.join('/') + '/' : ''}${relPath.join('/')}` + : `@${effectiveUser}/${treePath.length > 0 ? treePath.join('/') + '/' : ''}${relPath.join('/')}`; + void (async () => { + const content = await getAPI().readFile(apiFilePath); + if (content !== null) { + const name = relPath[relPath.length - 1]; + const relHash = relPath.map(encodeURIComponent).join('/'); + const panelId = 'file:' + relHash; + const fileEntry: FileEntry = { name, isDirectory: false, modified: '', content }; + history.replaceState(null, '', location.pathname + '#' + relHash); + ideLayoutInstance!.openPanel(makeFilePanel(panelId, name, [fileEntry])); + } else if (navigateFn) { + navigateFn(href); + } + })(); + return; + } + } + // Fallback: full navigation (directories, unresolved paths) + if (navigateFn) navigateFn(href); + }); + }); + } + + const initialFilePanelId = 'file:' + hash; + + const sidebarTitle = treePath[0] || (effectiveWorld ? '#' + effectiveWorld : '@' + effectiveUser); + const sidebarRootEntry = treePath.length > 0 + ? flattenEntries(repository.tree).find(e => e.name === treePath[0]) + : undefined; + const sidebarPanel: PanelDefinition = { + id: 'sidebar', + title: sidebarTitle, + icon: accessIcon(sidebarRootEntry || {}) + FOLDER_SVG, + closable: false, + sticky: true, + render: (el) => { + el.innerHTML = renderSidebarTree(sidebarEntries, sidebarBasePath, sidebarExpandPath, 0); + bindSidebarFileHandlers(el); + bindSidebarTreeHandlers(el); + bindAccessBadges(el); + }, + }; + + const filePanel = makeFilePanel(initialFilePanelId, fileName, files); + + // Try restoring layout from session + const currentUser = getCurrentPlayer(); + const session = loadSession(currentUser); + let initialLayout: LayoutNode; + const allPanels: PanelDefinition[] = [sidebarPanel, filePanel]; + + if (session.ideLayout && session.ideLayoutBase === sidebarBasePath) { + // Restore saved layout — resolve extra file panels from saved IDs + const savedLayout = session.ideLayout as LayoutNode; + const savedPanelIds = collectPanelIds(savedLayout); + const validIds = new Set(['sidebar', initialFilePanelId]); + + for (const pid of savedPanelIds) { + if (pid === 'sidebar' || pid === initialFilePanelId) continue; + if (pid.startsWith('file:')) { + const hashRel = pid.slice(5); + const relPath = hashRel.split('/').filter(Boolean); + if (relPath.length > 0) { + const resolved = resolveFiles(sidebarEntries, relPath); + if (resolved.length > 0) { + const name = relPath[relPath.length - 1]; + allPanels.push(makeFilePanel(pid, name, resolved)); + validIds.add(pid); + } + } + } + } + + const filtered = filterLayoutPanels(savedLayout, validIds); + if (filtered) { + initialLayout = filtered; + ensureIdCounter(maxIdInLayout(initialLayout)); + } else { + const sidebarGroupId = generateId(); + const fileGroupId = generateId(); + initialLayout = { + type: 'split', id: generateId(), direction: 'horizontal', + children: [ + { type: 'tabgroup', id: sidebarGroupId, panels: ['sidebar'], activeIndex: 0 }, + { type: 'tabgroup', id: fileGroupId, panels: [initialFilePanelId], activeIndex: 0 }, + ], + sizes: [0.2, 0.8], + }; + } + } else { + const sidebarGroupId = generateId(); + const fileGroupId = generateId(); + initialLayout = { + type: 'split', id: generateId(), direction: 'horizontal', + children: [ + { type: 'tabgroup', id: sidebarGroupId, panels: ['sidebar'], activeIndex: 0 }, + { type: 'tabgroup', id: fileGroupId, panels: [initialFilePanelId], activeIndex: 0 }, + ], + sizes: [0.2, 0.8], + }; + } + + ideLayoutInstance = createIDELayout(layoutMount, { + panels: allPanels, + initialLayout, + onNavigate: navigateFn || undefined, + onActiveTabChange: (panelId) => { + if (panelId.startsWith('file:')) { + const relHash = panelId.slice(5); + const target = location.pathname + '#' + relHash; + if (location.pathname + location.hash !== target) { + history.replaceState(null, '', target); + } + } + saveIDESession(currentUser, sidebarBasePath); + }, + onLayoutChange: () => { + saveIDESession(currentUser, sidebarBasePath); + // Re-trigger virtual scroll after layout changes (panels may have moved to new scroll context) + requestAnimationFrame(() => window.dispatchEvent(new Event('scroll'))); + }, + }); + // Store for hash fast-path in update() + currentFileViewEntries = sidebarEntries; + currentFileViewBasePath = sidebarBasePath; + currentMakeFilePanel = makeFilePanel; + } + return; + } + } + + // ---- Check for index.ray.js (sandboxed iframe mode) ---- + const flat = flattenEntries(entries); + const indexRay = !showUsersListing && !showWorldsListing && !hasWildcard + ? flat.find(e => e.name === 'index.ray.js' && !e.isDirectory && e.content) + : null; + + if (indexRay) { + if (iframeCleanup) { iframeCleanup(); iframeCleanup = null; } + + const canonicalPath = buildCanonicalPath(effectiveUser, effectiveWorld, treePath); + + // Build the header label from the chain + const headerLabel = headerChain.map(item => item.label).join(' / '); + + // Full-viewport iframe with overlay badge in bottom-right + const iframeStarred = isStarred(canonicalPath); + const iframeStarSvg = iframeStarred ? STAR_FILLED_SVG : STAR_OUTLINE_SVG; + const iframeStarCls = iframeStarred ? 'star-btn starred' : 'star-btn'; + const iframeStarLabel = iframeStarred ? 'Starred' : 'Star'; + currentContainer.innerHTML = `
    +
    + ${headerLabel} + ${repository.description} + + + + ${renderClonePopup(canonicalPath)} +
    +
    `; + + const repoPage = currentContainer.querySelector('.repo-page') as HTMLElement; + mountIframe(repoPage, indexRay.content!, canonicalPath); + + // Wire star button in iframe overlay + const iframeStarBtn = currentContainer.querySelector('[data-star-toggle]') as HTMLButtonElement | null; + if (iframeStarBtn) { + iframeStarBtn.addEventListener('click', () => { + const p = iframeStarBtn.dataset.starPath!; + const nowStarred = toggleStar(p); + const count = getStarCount(p) + (nowStarred ? 1 : -1); + setStarCount(p, count); + iframeStarBtn.className = nowStarred ? 'action-btn star-btn starred' : 'action-btn star-btn'; + iframeStarBtn.innerHTML = `${Math.max(0, count)}${nowStarred ? STAR_FILLED_SVG : STAR_OUTLINE_SVG}${nowStarred ? 'Starred' : 'Star'}`; + }); + } + + // Wire clone popup in iframe overlay + const overlay = currentContainer.querySelector('.iframe-overlay'); + if (overlay) { + const cloneToggle = overlay.querySelector('[data-clone-toggle]'); + const popup = overlay.querySelector('[data-clone-popup]') as HTMLElement | null; + const backdrop = overlay.querySelector('[data-clone-backdrop]') as HTMLElement | null; + if (cloneToggle && popup && backdrop) { + const close = () => { popup.classList.remove('open'); backdrop.classList.remove('open'); }; + cloneToggle.addEventListener('click', (e) => { + e.stopPropagation(); + const open = popup.classList.toggle('open'); + backdrop.classList.toggle('open', open); + }); + backdrop.addEventListener('click', close); + } + // Copy buttons + overlay.querySelectorAll('[data-copy]').forEach(btn => { + btn.addEventListener('click', () => { + const text = (btn as HTMLElement).dataset.copy!; + navigator.clipboard.writeText(text).then(() => { + btn.classList.add('copied'); + setTimeout(() => btn.classList.remove('copied'), 1500); + }); + }); + }); + } + return; + } + + // ---- Build URLs ---- + const basePath = buildBasePath(base, versions, path); + const displayVersion = versions.length > 0 ? versions[versions.length - 1][1] : 'latest'; + + let html = `
    `; + + // Compute clone/star path early so it's available for star row + let clonePath = buildCanonicalPath(effectiveUser, effectiveWorld, treePath); + // Strip implicit @user prefix when not explicit in URL, keep if top-level root + if (!base) { + const prefix = `@${user}/`; + if (clonePath.startsWith(prefix)) { + clonePath = clonePath.slice(prefix.length); + } + } + + // Header: chain of context switches, parents muted, last item bright + html += renderHeaderChain(headerChain, base, versions, path); + html += `
    ${repository.description}
    `; + + const { rootLink, items: breadcrumbItems } = buildBreadcrumbItems(treePath, headerChain, base, versions, path, treePathStart, repository.tree); + const rootStarPath = buildRootStarPath(repository, effectiveUser, effectiveWorld, treePath, user, base); + html += await renderBreadcrumb(displayVersion, breadcrumbItems, clonePath, rootStarPath, rootLink); + + // File listing + if (showUsersListing) { + const usersBase = buildBasePath(base, versions, path.slice(0, -1)); + html += `
    ${(entries as FileEntry[]).map(entry => { + const href = buildEntryHref(usersBase + '/@', entry.name, '@'); + return `
    +
    ${accessIcon(entry)}
    +
    ${fileOrEncryptedIcon(entry)}
    +
    ${displayEntryName(entry.name, '@')}
    +
    ${entry.modified}
    +
    `; + }).join('')}
    `; + } else if (showWorldsListing) { + const worldsBase = buildBasePath(base, versions, path.slice(0, -1)); + html += `
    ${(entries as FileEntry[]).map(entry => { + const href = buildEntryHref(worldsBase + '/~', entry.name, '~'); + return `
    +
    ${accessIcon(entry)}
    +
    ${fileOrEncryptedIcon(entry)}
    +
    ${displayEntryName(entry.name, '~')}
    +
    ${entry.modified}
    +
    `; + }).join('')}
    `; + } else { + html += renderFileListing(entries, basePath); + } + + // README — resolve relative links against current basePath + const readmes = findReadmes(entries); + // Fetch content for READMEs that lack inline content (e.g. HttpBackend flat listings) + for (const readme of readmes) { + if (!readme.content) { + const apiPath = effectiveWorld + ? `@${effectiveWorldParent}/~${effectiveWorld}/${treePath.length > 0 ? treePath.join('/') + '/' : ''}${readme.name}` + : `@${effectiveUser}/${treePath.length > 0 ? treePath.join('/') + '/' : ''}${readme.name}`; + const fetched = await getAPI().readFile(apiPath); + if (fetched !== null) readme.content = fetched; + } + } + const readmesWithContent = readmes.filter(r => r.content); + if (readmesWithContent.length === 1) { + const readmeHtml = renderMarkdown(readmesWithContent[0].content!); + const resolvedHtml = readmeHtml.replace(/href="(?!\/|https?:|#)([^"]+)"/g, (_m, rel) => + `href="${buildBasePath(base, versions, [...path, ...rel.split('/').filter(Boolean)])}"` + ); + html += `
    +
    README.md
    +
    ${resolvedHtml}
    +
    `; + } else if (readmesWithContent.length > 1) { + // Multiple READMEs — render as switchable tabs + const allSameName = readmesWithContent.every(r => r.name === readmesWithContent[0].name); + html += `
    `; + html += `
    `; + readmesWithContent.forEach((r, i) => { + const label = allSameName ? `${r.name} (${i + 1})` : r.name; + const activeClass = i === 0 ? ' active' : ''; + html += ``; + }); + html += `
    `; + readmesWithContent.forEach((r, i) => { + const readmeHtml = renderMarkdown(r.content!); + const resolvedHtml = readmeHtml.replace(/href="(?!\/|https?:|#)([^"]+)"/g, (_m, rel) => + `href="${buildBasePath(base, versions, [...path, ...rel.split('/').filter(Boolean)])}"` + ); + const hiddenClass = i === 0 ? '' : ' hidden'; + html += `
    ${resolvedHtml}
    `; + }); + html += `
    `; + } + + html += `
    `; + currentContainer.innerHTML = html; + bindClickHandlers(); +} + +// ---- Public API ---- + +export async function mount( + container: HTMLElement, + params: { user: string; path: string[]; versions: [number, string][]; base: string; hash: string | null }, + navigate: (path: string) => void, +): Promise { + injectStyles(); + document.body.style.background = CRT_SCREEN_BG; + currentContainer = container; + currentRepoParams = params; + navigateFn = navigate; + await renderRepo(); +} + +export async function update(params: { user: string; path: string[]; versions: [number, string][]; base: string; hash: string | null }): Promise { + const prev = currentRepoParams; + currentRepoParams = params; + + // Fast path: only hash changed while IDE layout is active + if (prev && ideLayoutInstance && currentFileViewEntries && currentMakeFilePanel && + params.user === prev.user && + params.base === prev.base && + params.hash !== prev.hash && + params.path.length === prev.path.length && + params.path.every((s, i) => s === prev.path[i]) && + params.versions.length === prev.versions.length && + params.versions.every(([d, v], i) => d === prev.versions[i][0] && v === prev.versions[i][1])) { + if (params.hash) { + const hashPath = params.hash.split('/').filter(Boolean); + if (hashPath.length > 0) { + const files = resolveFiles(currentFileViewEntries, hashPath); + const relHash = params.hash; + const panelId = 'file:' + relHash; + const name = hashPath[hashPath.length - 1]; + ideLayoutInstance.openPanel(currentMakeFilePanel(panelId, name, files)); + return; + } + } + // Hash cleared — fall through to full re-render (back to directory listing) + } + + await renderRepo(); +} + +export function unmount(): void { + if (saveTimeout) { clearTimeout(saveTimeout); saveTimeout = null; } + if (ideLayoutInstance) { ideLayoutInstance.unmount(); ideLayoutInstance = null; } + if (iframeCleanup) { iframeCleanup(); iframeCleanup = null; } + if (virtualScrollCleanup) { virtualScrollCleanup(); virtualScrollCleanup = null; } + sidebarExpanded.clear(); + if (currentContainer) { + currentContainer.innerHTML = ''; + currentContainer = null; + } + currentRepoParams = null; + currentFileViewEntries = null; + currentFileViewBasePath = null; + currentMakeFilePanel = null; + navigateFn = null; +} diff --git a/@ether/.html/UI/Router.ts b/@ether/.html/UI/Router.ts new file mode 100644 index 00000000..efd603ce --- /dev/null +++ b/@ether/.html/UI/Router.ts @@ -0,0 +1,253 @@ +// ============================================================ +// Router.ts — History API router (entry point) +// ============================================================ + +import * as Greetings from './Greetings.ts'; +import * as Repository from './Repository.ts'; +import * as PullRequests from './PullRequests.ts'; +import { getDefaultUser } from './API.ts'; + +type Page = 'repository' | 'pull-requests'; + +// Pages where the global command bar (@/slash overlay + @me button) is active. +const COMMAND_BAR_PAGES: Set = new Set(['repository', 'pull-requests']); + +let currentPage: Page | null = null; +let rootEl: HTMLElement; + +// ---- Route Params ---- + +interface RepoParams { + user: string; + path: string[]; + versions: [number, string][]; // [depth, version] pairs — e.g. [[0,'latest'],[1,'v2']] + base: string; // URL prefix for this player ('' for root, '/@user' for @-routes) + hash: string | null; // file selection from URL hash (without the '#') +} + +export interface PRParams { + user: string; + path: string[]; + base: string; + prAction: 'list' | 'detail' | 'new' | 'players' | 'worlds'; + prId: number | null; + commitId: string | null; + /** The canonical repo path for this PR context, e.g. "@ether/library" */ + repoPath: string; + /** Category context: '@' for players, '~' for worlds, null for regular */ + category: '@' | '~' | null; +} + +type RouteResult = + | { page: 'repository'; params: RepoParams } + | { page: 'pull-requests'; params: PRParams }; + +// ---- Route Matching ---- + +function matchRoute(pathname: string): RouteResult { + let user = getDefaultUser(); + let base = ''; + let rest = pathname; + + // Extract @user prefix + const atMatch = pathname.match(/^\/@([^/]+)(\/.*)?$/); + if (atMatch) { + user = atMatch[1]; + base = `/@${user}`; + rest = atMatch[2] || ''; + } + + // Split remaining into segments, filter empty + const segments = rest.split('/').filter(s => s); + + // Check for -/pulls, -/@/pulls, or -/~/pulls namespace separator + const dashIdx = segments.indexOf('-'); + if (dashIdx >= 0) { + let isPullsRoute = false; + let category: '@' | '~' | null = null; + let pullsSegmentsStart = dashIdx + 2; + + if (segments[dashIdx + 1] === 'pulls') { + isPullsRoute = true; + } else if ( + (segments[dashIdx + 1] === '@' || segments[dashIdx + 1] === '~') && + segments[dashIdx + 2] === 'pulls' + ) { + isPullsRoute = true; + category = segments[dashIdx + 1] as '@' | '~'; + pullsSegmentsStart = dashIdx + 3; + } + + if (isPullsRoute) { + const repoPathSegments = segments.slice(0, dashIdx).filter(s => s !== '*' && s !== '**'); + const pullsSegments = segments.slice(pullsSegmentsStart); + const repoPath = `@${user}` + (repoPathSegments.length > 0 ? '/' + repoPathSegments.join('/') : ''); + + let prAction: 'list' | 'detail' | 'new' | 'players' | 'worlds' = 'list'; + let prId: number | null = null; + let commitId: string | null = null; + + if (category && pullsSegments.length === 0) { + prAction = category === '@' ? 'players' : 'worlds'; + } else if (pullsSegments.length === 0) { + prAction = 'list'; + } else if (pullsSegments[0] === 'new') { + prAction = 'new'; + } else { + const id = parseInt(pullsSegments[0], 10); + if (!isNaN(id)) { + prAction = 'detail'; + prId = id; + if (pullsSegments[1] === 'commits' && pullsSegments[2]) { + commitId = pullsSegments[2]; + } + } + } + + return { + page: 'pull-requests', + params: { user, path: repoPathSegments, base, prAction, prId, commitId, repoPath, category }, + }; + } + } + + // Walk segments: collect ~/version markers and build clean path + const path: string[] = []; + const versions: [number, string][] = []; + let i = 0; + while (i < segments.length) { + if (segments[i] === '~') { + if (i + 1 < segments.length) { + versions.push([path.length, segments[i + 1]]); + i += 2; + } else { + // Trailing ~ with no version — worlds listing marker + path.push('~'); + i++; + } + } else { + path.push(segments[i]); + i++; + } + } + + return { page: 'repository', params: { user, path, versions, base, hash: null } }; +} + +// ---- Page Lifecycle ---- + +async function activatePage(route: RouteResult): Promise { + // Fast-path: same page type, just update params + if (route.page === 'repository' && currentPage === 'repository') { + await Repository.update(route.params); + ensureBar(route.page); + return; + } + if (route.page === 'pull-requests' && currentPage === 'pull-requests') { + await PullRequests.update(route.params); + ensureBar(route.page); + return; + } + + // Unmount current page + if (currentPage === 'repository') { + Repository.unmount(); + } else if (currentPage === 'pull-requests') { + PullRequests.unmount(); + } + + // Tear down global bar when navigating to an unsupported page + if (!COMMAND_BAR_PAGES.has(route.page) && Greetings.isGlobalBarActive()) { + Greetings.teardownGlobalBar(); + } + + currentPage = route.page; + + // Mount new page + if (route.page === 'repository') { + rootEl.style.display = ''; + await Repository.mount(rootEl, route.params, navigateTo); + } else if (route.page === 'pull-requests') { + rootEl.style.display = ''; + await PullRequests.mount(rootEl, route.params, navigateTo); + } + + ensureBar(route.page); +} + +function ensureBar(page: Page): void { + if (COMMAND_BAR_PAGES.has(page) && !Greetings.isFirstVisit()) { + Greetings.ensureGlobalBar(); // no-op if already active + } +} + +// ---- Navigation ---- + +let lastRouteUrl = ''; + +export function navigateTo(path: string): void { + lastRouteUrl = ''; // force re-evaluation + history.pushState(null, '', path); + handleRoute(); +} + +function handleRoute(): void { + // Guard against duplicate handling (both popstate and hashchange can fire) + const url = window.location.pathname + window.location.hash; + if (url === lastRouteUrl) return; + lastRouteUrl = url; + const route = matchRoute(window.location.pathname); + if (route.page === 'repository') { + route.params.hash = window.location.hash ? decodeURIComponent(window.location.hash.slice(1)) : null; + } + activatePage(route); +} + +// ---- Link Interception ---- + +function onLinkClick(e: MouseEvent): void { + if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; + + const anchor = (e.target as HTMLElement).closest('a[href]') as HTMLAnchorElement | null; + if (!anchor) return; + + const href = anchor.getAttribute('href'); + if (!href || href.startsWith('http') || href.startsWith('//')) return; + + e.preventDefault(); + navigateTo(href); +} + +// ---- Boot ---- + +function isHomepage(): boolean { + const p = window.location.pathname; + return p === '/' || p === ''; +} + +function boot(): void { + rootEl = document.getElementById('root')!; + + window.addEventListener('popstate', handleRoute); + window.addEventListener('hashchange', handleRoute); + document.addEventListener('click', onLinkClick); + + // Homepage first visit: show CRT onboarding first, render page after name is chosen + if (isHomepage() && Greetings.isFirstVisit()) { + Greetings.mount().then(() => { + lastRouteUrl = ''; // force re-evaluation with the newly stored name + handleRoute(); + // Fade in the page now that content is rendered with the correct user + rootEl.style.transition = 'opacity 0.6s ease-in'; + rootEl.style.opacity = '1'; + rootEl.addEventListener('transitionend', () => { + rootEl.style.transition = ''; + rootEl.style.opacity = ''; + }, { once: true }); + }); + } else { + handleRoute(); + } +} + +document.addEventListener('DOMContentLoaded', boot); diff --git a/@ether/.html/UI/backends/DummyBackend.ts b/@ether/.html/UI/backends/DummyBackend.ts new file mode 100644 index 00000000..994c4f38 --- /dev/null +++ b/@ether/.html/UI/backends/DummyBackend.ts @@ -0,0 +1,84 @@ +// ============================================================ +// DummyBackend.ts — Wraps existing DummyData.ts in EtherAPI interface +// ============================================================ +// Zero behavior change — everything keeps working with hardcoded mock data. + +import type { EtherAPI } from '../EtherAPI.ts'; +import type { + FileEntry, Repository, PullRequest, InlinePR, CategoryPRSummary, +} from '../DummyData.ts'; +import { + getRepository as _getRepository, + getWorld as _getWorld, + getReferencedUsers as _getReferencedUsers, + getReferencedWorlds as _getReferencedWorlds, + getPullRequests as _getPullRequests, + getPullRequest as _getPullRequest, + getInlinePullRequests as _getInlinePullRequests, + getOpenPRCount as _getOpenPRCount, + getCategoryPRSummary as _getCategoryPRSummary, + getCategoryPullRequests as _getCategoryPullRequests, + createPullRequest as _createPullRequest, +} from '../DummyData.ts'; + +export class DummyBackend implements EtherAPI { + async listDirectory(_path: string): Promise { + // DummyData doesn't have a flat directory listing — return empty + return []; + } + + async readFile(_path: string): Promise { + return null; + } + + async getRepository(user: string): Promise { + return _getRepository(user); + } + + async getWorld(user: string, world: string): Promise { + return _getWorld(user, world); + } + + async getReferencedUsers(user: string, world?: string | null): Promise { + return _getReferencedUsers(user, world); + } + + async getReferencedWorlds(user: string, world?: string | null): Promise { + return _getReferencedWorlds(user, world); + } + + async getPullRequests(canonicalPath: string): Promise { + return _getPullRequests(canonicalPath); + } + + async getPullRequest(canonicalPath: string, prId: number): Promise { + return _getPullRequest(canonicalPath, prId); + } + + async getInlinePullRequests(canonicalPath: string): Promise { + return _getInlinePullRequests(canonicalPath); + } + + async getOpenPRCount(canonicalPath: string): Promise { + return _getOpenPRCount(canonicalPath); + } + + async getCategoryPRSummary(canonicalPath: string, categoryPrefix: '~' | '@'): Promise { + return _getCategoryPRSummary(canonicalPath, categoryPrefix); + } + + async getCategoryPullRequests(canonicalPath: string, categoryPrefix: '~' | '@'): Promise { + return _getCategoryPullRequests(canonicalPath, categoryPrefix); + } + + async createPullRequest( + canonicalPath: string, + title: string, + description: string, + sourceLabel: string, + targetLabel: string, + author?: string, + ): Promise { + return _createPullRequest(canonicalPath, title, description, sourceLabel, targetLabel, author); + } +} diff --git a/@ether/.html/UI/backends/HttpBackend.ts b/@ether/.html/UI/backends/HttpBackend.ts new file mode 100644 index 00000000..de6e8035 --- /dev/null +++ b/@ether/.html/UI/backends/HttpBackend.ts @@ -0,0 +1,119 @@ +// ============================================================ +// HttpBackend.ts — HTTP backend for web + self-hosted servers +// ============================================================ +// Uses the /**/ URL convention to access the @ether/ filesystem. +// - /**/path/to/dir → JSON directory listing +// - /**/path/to/file → raw file content + +import type { EtherAPI } from '../EtherAPI.ts'; +import type { + FileEntry, Repository, PullRequest, InlinePR, CategoryPRSummary, +} from '../DummyData.ts'; + +export class HttpBackend implements EtherAPI { + constructor(private baseUrl: string) {} + + /** Encode a logical path for use in a fetch URL, encoding each segment individually. */ + private encodePath(path: string): string { + if (!path) return ''; + return path.split('/').map(s => encodeURIComponent(s)).join('/'); + } + + async listDirectory(path: string): Promise { + const url = `${this.baseUrl}/**/${this.encodePath(path)}`; + try { + const resp = await fetch(url); + if (!resp.ok) return []; + const entries: { name: string; isDirectory: boolean; size?: number }[] = await resp.json(); + return entries.map(e => ({ + name: e.name, + isDirectory: e.isDirectory, + modified: '', + })); + } catch { + return []; + } + } + + async readFile(path: string): Promise { + const url = `${this.baseUrl}/**/${this.encodePath(path)}`; + try { + const resp = await fetch(url); + if (!resp.ok) return null; + return await resp.text(); + } catch { + return null; + } + } + + async getRepository(user: string): Promise { + let entries = await this.listDirectory(`@${user}`); + // Fall back to root listing (.ether/) for the local player + if (entries.length === 0) { + entries = await this.listDirectory(''); + } + if (entries.length === 0) return null; + return { + user, + description: `@${user}`, + tree: entries, + }; + } + + async getWorld(user: string, world: string): Promise { + const entries = await this.listDirectory(`@${user}/~${world}`); + if (entries.length === 0) return null; + return { + user: world, + description: `#${world}`, + tree: entries, + }; + } + + async getReferencedUsers(_user: string, _world?: string | null): Promise { + // TODO: fetch from a metadata endpoint or scan directory + return []; + } + + async getReferencedWorlds(_user: string, _world?: string | null): Promise { + // TODO: fetch from a metadata endpoint or scan directory + return []; + } + + async getPullRequests(_canonicalPath: string): Promise { + // TODO: fetch from API endpoint + return []; + } + + async getPullRequest(_canonicalPath: string, _prId: number): Promise { + // TODO: fetch from API endpoint + return null; + } + + async getInlinePullRequests(_canonicalPath: string): Promise { + return []; + } + + async getOpenPRCount(_canonicalPath: string): Promise { + return 0; + } + + async getCategoryPRSummary(_canonicalPath: string, _categoryPrefix: '~' | '@'): Promise { + return null; + } + + async getCategoryPullRequests(_canonicalPath: string, _categoryPrefix: '~' | '@'): Promise { + return []; + } + + async createPullRequest( + _canonicalPath: string, + _title: string, + _description: string, + _sourceLabel: string, + _targetLabel: string, + _author?: string, + ): Promise { + throw new Error('createPullRequest not yet implemented for HttpBackend'); + } +} diff --git a/@ether/.html/UI/backends/TauriBackend.ts b/@ether/.html/UI/backends/TauriBackend.ts new file mode 100644 index 00000000..bd56d5e7 --- /dev/null +++ b/@ether/.html/UI/backends/TauriBackend.ts @@ -0,0 +1,120 @@ +// ============================================================ +// TauriBackend.ts — Tauri IPC backend (desktop + mobile) +// ============================================================ +// Calls Rust commands via window.__TAURI_INTERNALS__.invoke(). +// No @tauri-apps/api dependency needed. + +import type { EtherAPI } from '../EtherAPI.ts'; +import type { + FileEntry, Repository, PullRequest, InlinePR, CategoryPRSummary, +} from '../DummyData.ts'; + +declare global { + interface Window { + __TAURI_INTERNALS__?: { + invoke(cmd: string, args?: Record): Promise; + }; + } +} + +function invoke(cmd: string, args?: Record): Promise { + const tauri = window.__TAURI_INTERNALS__; + if (!tauri) return Promise.reject(new Error('Tauri IPC not available')); + return tauri.invoke(cmd, args); +} + +interface DirEntry { + name: string; + is_directory: boolean; + size: number | null; +} + +export class TauriBackend implements EtherAPI { + async listDirectory(path: string): Promise { + const entries = await invoke('list_directory', { path }); + return entries.map(e => ({ + name: e.name, + isDirectory: e.is_directory, + modified: '', + })); + } + + async readFile(path: string): Promise { + try { + return await invoke('read_file', { path }); + } catch { + return null; + } + } + + async getRepository(user: string): Promise { + const exists = await invoke('file_exists', { path: '' }); + if (!exists) return null; + const entries = await this.listDirectory(''); + return { + user, + description: `@${user}`, + tree: entries, + }; + } + + async getWorld(user: string, world: string): Promise { + // Worlds are stored as directories — check if the path exists + const worldPath = `~${world}`; + const exists = await invoke('file_exists', { path: worldPath }); + if (!exists) return null; + const entries = await this.listDirectory(worldPath); + return { + user: world, + description: `#${world}`, + tree: entries, + }; + } + + async getReferencedUsers(_user: string, _world?: string | null): Promise { + // TODO: scan filesystem for @-prefixed directories or metadata + return []; + } + + async getReferencedWorlds(_user: string, _world?: string | null): Promise { + // TODO: scan filesystem for ~-prefixed directories or metadata + return []; + } + + async getPullRequests(_canonicalPath: string): Promise { + // TODO: read from .ether/%/pull-requests/ directory + return []; + } + + async getPullRequest(_canonicalPath: string, _prId: number): Promise { + // TODO: read specific PR file from .ether/%/pull-requests/{id}.ray + return null; + } + + async getInlinePullRequests(_canonicalPath: string): Promise { + return []; + } + + async getOpenPRCount(_canonicalPath: string): Promise { + return 0; + } + + async getCategoryPRSummary(_canonicalPath: string, _categoryPrefix: '~' | '@'): Promise { + return null; + } + + async getCategoryPullRequests(_canonicalPath: string, _categoryPrefix: '~' | '@'): Promise { + return []; + } + + async createPullRequest( + _canonicalPath: string, + _title: string, + _description: string, + _sourceLabel: string, + _targetLabel: string, + _author?: string, + ): Promise { + throw new Error('createPullRequest not yet implemented for TauriBackend'); + } +} diff --git a/@ether/.html/env.d.ts b/@ether/.html/env.d.ts new file mode 100644 index 00000000..3c4ce73b --- /dev/null +++ b/@ether/.html/env.d.ts @@ -0,0 +1,6 @@ +/// + +declare module '*.ray?raw' { + const content: string; + export default content; +} diff --git a/Ether/.html/favicon.png b/@ether/.html/favicon.png similarity index 100% rename from Ether/.html/favicon.png rename to @ether/.html/favicon.png diff --git a/Ether/.html/index.css b/@ether/.html/index.css similarity index 70% rename from Ether/.html/index.css rename to @ether/.html/index.css index b70903ba..c5538df4 100644 --- a/Ether/.html/index.css +++ b/@ether/.html/index.css @@ -1,7 +1,7 @@ * { } body { - background-color: #1c2127; + background-color: #0a0a0a; margin: 0; } #container { diff --git a/Ether/.html/index.html b/@ether/.html/index.html similarity index 66% rename from Ether/.html/index.html rename to @ether/.html/index.html index 7c92a7c0..5cb2f6e7 100644 --- a/Ether/.html/index.html +++ b/@ether/.html/index.html @@ -3,7 +3,7 @@ - + @@ -23,13 +23,8 @@ - -
    -
    -
    - Ether -
    -
    -
    + + +
    \ No newline at end of file diff --git a/@ether/.html/index.ray b/@ether/.html/index.ray new file mode 100644 index 00000000..98778086 --- /dev/null +++ b/@ether/.html/index.ray @@ -0,0 +1,142 @@ +// === Class Definitions === +class Card + padding: 1.5rem + background.color: #2a3040 + color: #e0e0e0 + border.radius: 0.5rem + margin: 0.5rem + +class Title + font.size: 2rem + font.weight: bold + color: white + +class Subtitle + font.size: 1.2rem + color: #8899aa + +class Tag + padding: 0.25rem 0.75rem + background.color: #3a4a6a + border.radius: 1rem + font.size: 0.85rem + color: #aabbdd + +class FullHeight + min.height: 100vh + +class Muted + color: #667788 + font.size: 0.9rem + +// === Component Definitions === +class NavButton + padding: 0.75rem 1.5rem + background.color: #3a4a6a + border.radius: 0.5rem + color: white + Center.Horizontally "Click me".italic + +class InfoCard + padding: 1rem + border.radius: 0.5rem + + Center.Vertically + Node("Info").bold + Node("Details go here") + Muted + + background.color: #1e2a36 + +class Badge + padding: 0.25rem 0.5rem + background.color: #4a5a7a + border.radius: 0.25rem + font.size: 0.75rem + Node("new").uppercase + +// === Layout === +Center + FullHeight + 1 * Center Node("Ether").bold + Title + + // --- Between with fractional widths --- + 1 * Between + 1/3 * "Hello" + Card + 1/3 * ("World") + Card + 1/3 * ( + "!", + "!" + ) + Card + + // --- Percentage widths --- + 1 * Between + 25% * "Quarter".center + Card + 50% * "Half".center + Card + 25% * "Quarter".center + Card + + // --- Nested layout with constraints --- + Node{width <= 40rem}(padding: 2rem, background.color: #1c2127, border.radius: 0.5rem, width: 100%) + Between + Start + Node("Left") + Subtitle + End + Node("Right") + Subtitle + Start + Node("Left") + Subtitle + End + Node("Right") + Subtitle + + // --- Tags row --- + Center.Horizontally + Node("ray") + Tag, Node("ether") + Tag, "html" + Tag + + // --- Mixed fractions --- + Between + 1/4 * Node("25%", padding: 0.5rem, background.color: #2a3a4a, text.align: center) + Card + 1/2 * Node("50%", padding: 0.5rem, background.color: #3a4a5a, text.align: center) + Card + 1/4 * Node("25%", padding: 0.5rem, background.color: #2a3a4a, text.align: center) + Card + + // --- Grouping with parens --- + Center.Horizontally + ("Grouped A", "Grouped B") + Card + ("Grouped C", "Grouped D") + Card + + // --- Decimal fractions --- + Between + 0.5 * Node("Half", padding: 0.5rem, background.color: #334455) + Card + 0.5 * Node("Half", padding: 0.5rem, background.color: #445566) + Card + + // --- Text styles --- + Center.Horizontally + "italic text".italic + Card + "bold text".bold + Card + "underlined".underline + Card + Node("monospace").mono + Card + "LOUD".uppercase.bold + Card + + // --- Padding shortcuts --- + Between + Node("padded x") + Card + px 4 + Node("padded y") + Card + py 4 + Node("padded top") + Card + pt 8 + + // --- Map: apply class to each child --- + End ("A", "B", "C", InfoCard).map(+ Card + px 10) + + // --- Components --- + 1 * Center + 1 * NavButton + NavButton, Badge + + 100% * Between + InfoCard + InfoCard + + // --- Image --- + Center + Image(./images/Ether.svg, height: 120px) + Image(height: 120px, ./images/Ether.svg) + + // --- Vertical centering --- + Node(height: 100px) + Center.Vertically + Node("Vertically centered content") + Subtitle diff --git a/@ether/.html/index.ts b/@ether/.html/index.ts new file mode 100644 index 00000000..25b1bee1 --- /dev/null +++ b/@ether/.html/index.ts @@ -0,0 +1,940 @@ +// ============================================================ +// Ether UI Framework — Temporary Runtime +// ============================================================ + +import source from './index.ray?raw'; + +// --- CSS --- +const _s = document.createElement('style'); +_s.textContent = [ + `*, *::before, *::after { box-sizing: border-box; }`, + `.block { display: block; }`, + `.inline { display: inline; }`, + `.ether-center { display: flex; flex-direction: column; justify-content: center; align-items: center; height: 100%; }`, + `.ether-center-h { display: flex; justify-content: center; }`, + `.ether-center-v { display: flex; flex-direction: column; justify-content: center; height: 100%; }`, + `.ether-between { display: flex; justify-content: space-between; }`, + `.ether-start { display: flex; justify-content: flex-start; }`, + `.ether-end { display: flex; justify-content: flex-end; }`, +].join('\n'); +document.head.appendChild(_s); + +const _userStyles = document.createElement('style'); +document.head.appendChild(_userStyles); + +const LAYOUT: Record = { + 'Center': 'ether-center', + 'Center.Horizontally': 'ether-center-h', + 'Center.Vertically': 'ether-center-v', + 'Between': 'ether-between', + 'Start': 'ether-start', + 'End': 'ether-end', +}; + +const PAD: Record string> = { + 'pt': (r) => `padding-top:${r}rem;`, + 'pb': (r) => `padding-bottom:${r}rem;`, + 'pl': (r) => `padding-left:${r}rem;`, + 'pr': (r) => `padding-right:${r}rem;`, + 'px': (r) => `padding-left:${r}rem;padding-right:${r}rem;`, + 'py': (r) => `padding-top:${r}rem;padding-bottom:${r}rem;`, +}; + +const DOT_STYLE: Record = { + 'italic': 'font-style:italic;', + 'bold': 'font-weight:bold;', + 'underline': 'text-decoration:underline;', + 'strikethrough': 'text-decoration:line-through;', + 'uppercase': 'text-transform:uppercase;', + 'lowercase': 'text-transform:lowercase;', + 'mono': 'font-family:monospace;', + 'small': 'font-size:0.875rem;', + 'large': 'font-size:1.25rem;', + 'center': 'text-align:center;', + 'nowrap': 'white-space:nowrap;', +}; + +interface ComponentDef { children: INode[]; } +const COMPONENTS: Record = {}; + +// ============================================================ +// Reactive System +// ============================================================ + +type Effect = () => void; +let _tracking: Effect | null = null; +let _batching = false; +const _queue = new Set(); + +export function signal(init: T): [() => T, (v: T) => void] { + let val = init; + const deps = new Set(); + return [ + () => { if (_tracking) deps.add(_tracking); return val; }, + (v: T) => { + if (Object.is(val, v)) return; + val = v; + if (_batching) deps.forEach(d => _queue.add(d)); + else { for (const d of [...deps]) d(); } + } + ]; +} + +export function effect(fn: Effect): () => void { + const run = () => { const prev = _tracking; _tracking = run; fn(); _tracking = prev; }; + run(); + return () => {}; +} + +export function batch(fn: () => void) { + _batching = true; + fn(); + _batching = false; + const fns = [..._queue]; + _queue.clear(); + fns.forEach(f => f()); +} + +// ============================================================ +// Virtual DOM +// ============================================================ + +interface VEl { t: 'e'; tag: string; props: Record; ch: VN[]; dom?: HTMLElement; } +interface VTx { t: 't'; v: string; dom?: Text; } +type VN = VEl | VTx; + +function ve(tag: string, props: Record, ch: VN[]): VEl { + return { t: 'e', tag, props, ch }; +} +function vt(v: string): VTx { return { t: 't', v }; } +function vblock(ch: VN[]): VEl { return ve('div', { className: 'block' }, ch); } +function vspan(ch: VN[]): VEl { return ve('div', { className: 'inline' }, ch); } + +function addClassName(v: VN, cls: string): VN { + if (v.t === 't') return ve('div', { className: 'inline ' + cls }, [v]); + const cur = v.props.className || ''; + return { ...v, props: { ...v.props, className: cur ? cur + ' ' + cls : cls } }; +} + +function addStyle(v: VN, style: string): VN { + if (v.t === 't') return ve('div', { className: 'inline', style }, [v]); + const cur = v.props.style || ''; + return { ...v, props: { ...v.props, style: cur + style } }; +} + +function addWidth(v: VN, frac: number): VN { + const pct = (frac * 100).toFixed(4).replace(/\.?0+$/, ''); + return addStyle(v, `width:${pct}%;min-width:0;`); +} + +// ============================================================ +// DOM Operations +// ============================================================ + +function applyProps(d: HTMLElement, o: Record, n: Record) { + for (const k of Object.keys(o)) { + if (!(k in n)) { + if (k.startsWith('on')) d.removeEventListener(k.slice(2).toLowerCase(), o[k]); + else if (k === 'className') d.className = ''; + else if (k === 'style') d.style.cssText = ''; + else d.removeAttribute(k); + } + } + for (const [k, v] of Object.entries(n)) { + if (o[k] === v) continue; + if (k.startsWith('on')) { + if (o[k]) d.removeEventListener(k.slice(2).toLowerCase(), o[k]); + d.addEventListener(k.slice(2).toLowerCase(), v); + } else if (k === 'className') { d.className = v; } + else if (k === 'style') { d.style.cssText = v; } + else d.setAttribute(k, String(v)); + } +} + +function createDom(v: VN): Node { + if (v.t === 't') { const d = document.createTextNode(v.v); v.dom = d; return d; } + const d = document.createElement(v.tag); + v.dom = d; + applyProps(d, {}, v.props); + v.ch.forEach(c => d.appendChild(createDom(c))); + return d; +} + +function patch(par: Node, ov: VN | null, nv: VN | null, idx: number) { + const ch = par.childNodes[idx]; + if (!ov && nv) { par.appendChild(createDom(nv)); return; } + if (ov && !nv) { if (ch) par.removeChild(ch); return; } + if (!ov || !nv) return; + if (ov.t !== nv.t || (ov.t === 'e' && nv.t === 'e' && ov.tag !== nv.tag)) { + par.replaceChild(createDom(nv), ch); return; + } + if (ov.t === 't' && nv.t === 't') { + nv.dom = ov.dom; + if (ov.v !== nv.v && ch) ch.textContent = nv.v; + return; + } + if (ov.t === 'e' && nv.t === 'e') { + nv.dom = ov.dom; + applyProps(ch as HTMLElement, ov.props, nv.props); + const oc = ov.ch, nc = nv.ch; + const mx = Math.max(oc.length, nc.length); + for (let i = mx - 1; i >= 0; i--) patch(ch, oc[i] ?? null, nc[i] ?? null, i); + } +} + +// ============================================================ +// Mount +// ============================================================ + +export function mount(container: HTMLElement, render: () => VN): () => void { + let prev: VN | null = null; + return effect(() => { + const next = render(); + if (prev) { + patch(container, prev, next, 0); + } else { + container.innerHTML = ''; + container.appendChild(createDom(next)); + } + prev = next; + }); +} + +// ============================================================ +// Tokenizer +// ============================================================ + +type Tk = + | { t: 'id'; v: string } + | { t: 'str'; v: string } + | { t: 'num'; v: number } + | { t: '(' } | { t: ')' } + | { t: ',' } | { t: '+' } | { t: '*' } | { t: '/' } + | { t: ':' } | { t: 'val'; v: string } + | { t: '.' } | { t: '%' } + | { t: 'constraint'; v: string } + | { t: 'bsep' }; + +function tokenize(s: string): Tk[] { + const r: Tk[] = []; + let i = 0; + while (i < s.length) { + if (s[i] === ' ') { + let n = 0; while (i < s.length && s[i] === ' ') { n++; i++; } + if (n >= 2 && r.length > 0) r.push({ t: 'bsep' }); + continue; + } + if (s[i] === '(') { r.push({ t: '(' }); i++; continue; } + if (s[i] === ')') { r.push({ t: ')' }); i++; continue; } + if (s[i] === ',') { r.push({ t: ',' }); i++; continue; } + if (s[i] === '+') { r.push({ t: '+' }); i++; continue; } + if (s[i] === '*') { r.push({ t: '*' }); i++; continue; } + if (s[i] === '/') { r.push({ t: '/' }); i++; continue; } + if (s[i] === '%') { r.push({ t: '%' }); i++; continue; } + if (s[i] === '{') { + i++; + let content = ''; + while (i < s.length && s[i] !== '}') { content += s[i]; i++; } + if (i < s.length) i++; + r.push({ t: 'constraint', v: content.trim() }); + continue; + } + if (s[i] === '.') { r.push({ t: '.' }); i++; continue; } + if (s[i] === ':') { + r.push({ t: ':' }); i++; + while (i < s.length && s[i] === ' ') i++; + let val = ''; + while (i < s.length && s[i] !== ',' && s[i] !== ')') { val += s[i]; i++; } + val = val.trimEnd(); + if (val) r.push({ t: 'val', v: val }); + continue; + } + if (s[i] === '"' || s[i] === "'") { + const q = s[i]; i++; + let str = ''; + while (i < s.length && s[i] !== q) { + if (s[i] === '\\') { i++; str += s[i] || ''; } else str += s[i]; + i++; + } + i++; + r.push({ t: 'str', v: str }); + continue; + } + if (/[0-9]/.test(s[i])) { + let num = ''; + while (i < s.length && /[0-9.]/.test(s[i])) { num += s[i]; i++; } + r.push({ t: 'num', v: parseFloat(num) }); + continue; + } + if (/[a-zA-Z_$]/.test(s[i])) { + let id = ''; + while (i < s.length && /[a-zA-Z0-9_$]/.test(s[i])) { id += s[i]; i++; } + // Allow dots in identifiers only for known compound names (e.g. Center.Horizontally) + while (i < s.length && s[i] === '.' && i + 1 < s.length && /[a-zA-Z]/.test(s[i + 1])) { + let j = i + 1; + while (j < s.length && /[a-zA-Z0-9_$]/.test(s[j])) j++; + const full = id + '.' + s.slice(i + 1, j); + if (full in LAYOUT) { id = full; i = j; } + else break; + } + r.push({ t: 'id', v: id }); + continue; + } + i++; + } + return r; +} + +// ============================================================ +// Pre-processor: join multi-line parentheses +// ============================================================ + +function joinMultiLineParens(src: string): string { + const lines = src.split('\n'); + const result: string[] = []; + let buffer = ''; + let depth = 0; + for (const line of lines) { + if (depth > 0) { + buffer += ' ' + line.trimStart(); + } else { + if (buffer) result.push(buffer); + buffer = line; + } + for (let j = 0; j < line.length; j++) { + const c = line[j]; + if (c === '"' || c === "'") { + const q = c; j++; + while (j < line.length && line[j] !== q) { + if (line[j] === '\\') j++; + j++; + } + } else if (c === '(') depth++; + else if (c === ')') depth = Math.max(0, depth - 1); + } + } + if (buffer) result.push(buffer); + return result.join('\n'); +} + +// ============================================================ +// Indentation Tree +// ============================================================ + +interface INode { indent: number; content: string; children: INode[]; } + +function buildIndentTree(src: string): INode[] { + const lines: { indent: number; content: string }[] = []; + for (const raw of src.split('\n')) { + const m = raw.match(/^( *)(.*)/); + if (!m) continue; + const c = m[2].trimEnd(); + if (!c || c.startsWith('//')) continue; + lines.push({ indent: m[1].length, content: c }); + } + const root: INode[] = []; + const stack: { indent: number; children: INode[] }[] = [{ indent: -1, children: root }]; + for (const { indent, content } of lines) { + while (stack.length > 1 && stack[stack.length - 1].indent >= indent) stack.pop(); + const node: INode = { indent, content, children: [] }; + stack[stack.length - 1].children.push(node); + stack.push({ indent, children: node.children }); + } + return root; +} + +// ============================================================ +// Constraint Parsing: {width <= 1240px} → max-width:1240px; +// ============================================================ + +function parseConstraints(raw: string): string { + return raw.split(',').map(part => { + const m = part.trim().match(/^([\w.-]+)\s*([<>=!]+)\s*(.+)$/); + if (!m) return ''; + const cssProp = m[1].replace(/\./g, '-'); + const op = m[2]; + const v = m[3].trim(); + if (op === '==' || op === '=') return `${cssProp}:${v};`; + if (op === '<=' || op === '<') return `max-${cssProp}:${v};`; + if (op === '>=' || op === '>') return `min-${cssProp}:${v};`; + return ''; + }).join(''); +} + +// ============================================================ +// Class Definition Processing +// ============================================================ + +function processClassDefs(nodes: INode[]) { + let css = ''; + for (const k of Object.keys(COMPONENTS)) delete COMPONENTS[k]; + for (const node of nodes) { + const name = node.content.replace(/^class\s+/, '').trim(); + const rules: string[] = []; + const contentChildren: INode[] = []; + for (const child of node.children) { + const idx = child.content.indexOf(':'); + if (idx !== -1 && !/[("']/.test(child.content.slice(0, idx))) { + const key = child.content.slice(0, idx).trim().replace(/\./g, '-'); + const val = child.content.slice(idx + 1).trim(); + rules.push(`${key}: ${val}`); + } else { + contentChildren.push(child); + } + } + if (rules.length) css += `.${name} { ${rules.join('; ')}; }\n`; + if (contentChildren.length > 0) { + COMPONENTS[name] = { children: contentChildren }; + } + } + _userStyles.textContent = css; +} + +// ============================================================ +// Token Parser +// ============================================================ + +class TkParser { + tks: Tk[]; pos: number; raw: string; + constructor(tks: Tk[], raw = '') { this.tks = tks; this.pos = 0; this.raw = raw; } + peek(off = 0): Tk | null { const i = this.pos + off; return i < this.tks.length ? this.tks[i] : null; } + next(): Tk | null { return this.pos < this.tks.length ? this.tks[this.pos++] : null; } + + // Check if there are any top-level commas from current pos onward + hasTopLevelComma(): boolean { + let depth = 0; + for (let i = this.pos + 1; i < this.tks.length; i++) { + if (this.tks[i].t === '(') depth++; + else if (this.tks[i].t === ')') depth--; + else if (this.tks[i].t === ',' && depth === 0) return true; + } + return false; + } + + // Check if id( or id{...}( at current position is a root call + isRootCall(): boolean { + let i = this.pos + 1; + if (i < this.tks.length && this.tks[i].t === 'constraint') i++; + if (i >= this.tks.length || this.tks[i].t !== '(') { + // id{constraint} without parens — treat as root if no comma after + while (i < this.tks.length) { + if (this.tks[i].t === '+') { i++; if (i < this.tks.length && this.tks[i].t === 'id') i++; } + else if (this.tks[i].t === '.') { i++; if (i < this.tks.length) i++; } + else break; + } + return !(i < this.tks.length && this.tks[i].t === ','); + } + let depth = 0; + for (; i < this.tks.length; i++) { + if (this.tks[i].t === '(') depth++; + else if (this.tks[i].t === ')') { depth--; if (depth === 0) { i++; break; } } + } + while (i < this.tks.length) { + if (this.tks[i].t === '+') { i++; if (i < this.tks.length && this.tks[i].t === 'id') i++; } + else if (this.tks[i].t === '.') { i++; if (i < this.tks.length) i++; } + else break; + } + return !(i < this.tks.length && this.tks[i].t === ','); + } + + parseLine(indentCh: VN[]): VN { + const first = this.peek(); + if (!first) return vblock(indentCh); + + // Layout keyword + if (first.t === 'id' && first.v in LAYOUT) { + return this.parseLayoutLine(LAYOUT[first.v], indentCh); + } + + // Component at line start (bare, no parens/constraint next, no sibling commas) + if (first.t === 'id' && first.v in COMPONENTS) { + const nx = this.peek(1); + if ((!nx || (nx.t !== '(' && nx.t !== 'constraint')) && !this.hasTopLevelComma()) { + return this.parseComponentLine(indentCh); + } + } + + // Root call: id(...) or id{...}(...) at start — only if no commas after + // Exclude Image — it's an inline element, not a container + if (first.t === 'id' && first.v !== 'Image') { + const nx = this.peek(1); + if ((nx?.t === '(' || nx?.t === 'constraint') && this.isRootCall()) { + return this.parseRootCall(indentCh); + } + } + + return this.parseRegularLine(indentCh); + } + + // Image args: properties + a path (quoted string or unquoted tokens) + parseImageCallArgs(): { src: string; style: string } { + let src = ''; + let style = ''; + while (this.peek() && this.peek()!.t !== ')') { + if (this.peek()?.t === ',') { this.next(); continue; } + // Property: id : val + if (this.peek()?.t === 'id' && this.peek(1)?.t === ':') { + const key = (this.next() as { v: string }).v.replace(/\./g, '-'); + this.next(); + const val = this.peek()?.t === 'val' ? (this.next() as { v: string }).v : ''; + style += `${key}: ${val}; `; + } else if (this.peek()?.t === 'str') { + src = (this.next() as { v: string }).v; + } else { + // Unquoted path — collect tokens until , or ) or property + let path = ''; + while (this.peek()) { + const pk = this.peek()!; + if (pk.t === ',' || pk.t === ')') break; + if (pk.t === 'id' && this.peek(1)?.t === ':') break; + const tk = this.next()!; + if (tk.t === 'id') path += tk.v; + else if (tk.t === '.') path += '.'; + else if (tk.t === '/') path += '/'; + else if (tk.t === 'num') path += String(tk.v); + } + if (path) src = path; + } + } + return { src, style: style.trim() }; + } + + parseLayoutLine(cls: string, indentCh: VN[]): VN { + this.next(); + let style = ''; + if (this.peek()?.t === 'constraint') { + style += parseConstraints((this.next() as { v: string }).v); + } + let args: VN[] = []; + if (this.peek()?.t === '(') { + this.next(); + const r = this.parseCallArgs(); + args = r.items; style += r.style; + if (this.peek()?.t === ')') this.next(); + } + const rest = this.parseItemList(true); + let node = ve('div', { className: cls }, [...args, ...rest, ...indentCh]); + if (style) node = addStyle(node, style) as VEl; + return this.applyPostfix(node); + } + + parseComponentLine(indentCh: VN[]): VN { + const name = (this.next() as { v: string }).v; + let style = ''; + if (this.peek()?.t === 'constraint') { + style += parseConstraints((this.next() as { v: string }).v); + } + const def = COMPONENTS[name]; + const defCh = def.children.map(c => iNodeToVN(c)); + const rest = this.parseItemList(true); + let node = vblock([...defCh, ...rest, ...indentCh]); + node = addClassName(node, name) as VEl; + + if (style) node = addStyle(node, style) as VEl; + return this.applyPostfix(node); + } + + parseRootCall(indentCh: VN[]): VN { + const name = (this.next() as { v: string }).v; + let style = ''; + if (this.peek()?.t === 'constraint') { + style += parseConstraints((this.next() as { v: string }).v); + } + let callItems: VN[] = []; + if (this.peek()?.t === '(') { + this.next(); + const r = this.parseCallArgs(); + callItems = r.items; style += r.style; + if (this.peek()?.t === ')') this.next(); + } + const rest = this.parseItemList(true); + if (name in COMPONENTS) { + const def = COMPONENTS[name]; + const defCh = def.children.map(c => iNodeToVN(c)); + let node = vblock([...defCh, ...callItems, ...rest, ...indentCh]); + node = addClassName(node, name) as VEl; + + if (style) node = addStyle(node, style) as VEl; + return this.applyPostfix(node); + } + let node = vblock([...callItems, ...rest, ...indentCh]); + if (style) node = addStyle(node, style) as VEl; + return this.applyPostfix(node); + } + + parseRegularLine(indentCh: VN[]): VN { + const segs = this.parseSegments(); + const blockCh: VN[] = []; + for (const seg of segs) { + for (const item of seg) blockCh.push(mergeIntoBlock(item)); + } + if (indentCh.length > 0 && blockCh.length > 0) { + const last = blockCh[blockCh.length - 1]; + if (last.t === 'e' && last.props.className?.includes('block')) { + blockCh[blockCh.length - 1] = { ...last, ch: [...last.ch, ...indentCh] }; + } else { + blockCh.push(...indentCh); + } + } else { + blockCh.push(...indentCh); + } + if (blockCh.length === 0) return vblock([]); + if (blockCh.length === 1) return blockCh[0]; + return ve('div', { style: 'display:inline-flex' }, blockCh); + } + + parseSegments(): VN[][] { + const segs: VN[][] = []; + while (this.peek()) { + const items = this.parseItemList(true); + if (items.length > 0) segs.push(items); + if (this.peek()?.t === 'bsep') this.next(); + else break; + } + return segs; + } + + parseItemList(stopAtBsep: boolean): VN[] { + const items: VN[] = []; + while (this.peek()) { + const p = this.peek()!; + if (p.t === ')' || p.t === '+' || p.t === '*' || p.t === '.') break; + if (p.t === 'bsep' && stopAtBsep) break; + if (p.t === ',') { this.next(); continue; } + items.push(this.parseItem()); + } + return items; + } + + parseCallArgs(): { items: VN[]; style: string } { + const items: VN[] = []; + let style = ''; + while (this.peek() && this.peek()!.t !== ')') { + if (this.peek()?.t === ',') { this.next(); continue; } + if (this.peek()?.t === 'id' && this.peek(1)?.t === ':') { + const key = (this.next() as { v: string }).v.replace(/\./g, '-'); + this.next(); + const val = this.peek()?.t === 'val' ? (this.next() as { v: string }).v : ''; + style += `${key}: ${val}; `; + } else { + items.push(this.parseItem()); + } + } + return { items, style: style.trim() }; + } + + parseItem(): VN { + if (this.peek()?.t === 'num') { + const saved = this.pos; + const n = this.next() as { v: number }; + let frac = n.v; + if (this.peek()?.t === '%') { + this.next(); + frac = frac / 100; + } else if (this.peek()?.t === '/') { + this.next(); + const d = this.next(); + if (d && d.t === 'num' && d.v !== 0) frac = frac / d.v; + } + if (this.peek()?.t === '*') { + this.next(); + let item = this.parseAtom(); + item = addWidth(item, frac); + return this.applyPostfix(item); + } + // Just a number — if we consumed extra tokens (% or /), show the fraction + if (this.pos !== saved + 1) { + return this.applyPostfix(vt(String(frac))); + } + // Plain number + return this.applyPostfix(vt(String(n.v))); + } + return this.applyPostfix(this.parseAtom()); + } + + parseAtom(): VN { + const t = this.peek(); + if (!t) return vt(''); + + if (t.t === 'str') { this.next(); return vt(t.v); } + if (t.t === 'num') { this.next(); return vt(String(t.v)); } + + if (t.t === 'id') { + this.next(); + + // Image: Image(src, properties...) — src is the path argument + if (t.v === 'Image') { + let style = ''; + if (this.peek()?.t === 'constraint') { + style = parseConstraints((this.next() as { v: string }).v); + } + if (this.peek()?.t === '(') { + this.next(); + const r = this.parseImageCallArgs(); + if (r.style) style += r.style; + if (this.peek()?.t === ')') this.next(); + const fileName = r.src.split('/').pop() || ''; + const alt = fileName.replace(/\.[^.]+$/, ''); + let node: VN = ve('img', { src: r.src, alt }, []); + if (style) node = addStyle(node, style); + return node; + } + return vspan([]); + } + + // Layout keyword inline + if (t.v in LAYOUT) return this.parseInlineLayout(LAYOUT[t.v]); + + // Component reference + if (t.v in COMPONENTS) return this.parseComponentAtom(t.v); + + // Constraint on identifier + let cStyle = ''; + if (this.peek()?.t === 'constraint') { + cStyle = parseConstraints((this.next() as { v: string }).v); + } + + // Call: Name(...) + if (this.peek()?.t === '(') { + this.next(); + const { items, style } = this.parseCallArgs(); + if (this.peek()?.t === ')') this.next(); + const trailing = this.parseTrailing(); + let node = vspan([...items, ...trailing]); + if (style) node = addStyle(node, style) as VEl; + if (cStyle) node = addStyle(node, cStyle) as VEl; + return node; + } + + // Plain identifier + let node: VN = vspan([]); + if (cStyle) node = addStyle(node, cStyle); + return node; + } + + // Grouping: (...) + if (t.t === '(') { + this.next(); + const r = this.parseCallArgs(); + if (this.peek()?.t === ')') this.next(); + let node = r.items.length > 1 + ? ve('div', { style: 'display:inline-flex' }, r.items) + : vspan(r.items); + if (r.style) node = addStyle(node, r.style) as VEl; + return node; + } + + this.next(); + return vt(''); + } + + parseComponentAtom(name: string): VN { + let cStyle = ''; + if (this.peek()?.t === 'constraint') { + cStyle = parseConstraints((this.next() as { v: string }).v); + } + const def = COMPONENTS[name]; + const defCh = def.children.map(c => iNodeToVN(c)); + if (this.peek()?.t === '(') { + this.next(); + const { items, style } = this.parseCallArgs(); + if (this.peek()?.t === ')') this.next(); + let node = ve('div', { className: name }, [...defCh, ...items]); + + if (style) node = addStyle(node, style) as VEl; + if (cStyle) node = addStyle(node, cStyle) as VEl; + return node; + } + let node = ve('div', { className: name }, defCh); + + if (cStyle) node = addStyle(node, cStyle) as VEl; + return node; + } + + parseInlineLayout(cls: string): VN { + let style = ''; + if (this.peek()?.t === 'constraint') { + style += parseConstraints((this.next() as { v: string }).v); + } + let args: VN[] = []; + if (this.peek()?.t === '(') { + this.next(); + const r = this.parseCallArgs(); + args = r.items; style += r.style; + if (this.peek()?.t === ')') this.next(); + } + const trailing = this.parseTrailing(); + let node = ve('div', { className: cls }, [...args, ...trailing]); + if (style) node = addStyle(node, style) as VEl; + return node; + } + + parseTrailing(): VN[] { + const items: VN[] = []; + while (this.peek()) { + const p = this.peek()!; + if (p.t === ',' || p.t === 'bsep' || p.t === ')' || p.t === '+' || p.t === '*' || p.t === '.') break; + items.push(this.parseItem()); + } + return items; + } + + applyPostfix(item: VN): VN { + while (this.peek()) { + const p = this.peek()!; + if (p.t === '+') { + this.next(); + const id = this.peek(); + if (id && id.t === 'id') { + // Padding shortcut: + pt 5 + if (id.v in PAD && this.peek(1)?.t === 'num') { + this.next(); + const n = (this.next() as { v: number }).v; + item = addStyle(item, PAD[id.v](n * 0.125)); + } else { + this.next(); + item = addClassName(item, id.v); + } + } + } else if (p.t === '*') { + this.next(); + item = addWidth(item, this.parseFractionRaw()); + } else if (p.t === '.') { + // Peek ahead: need . followed by id + const nxt = this.peek(1); + if (!nxt || nxt.t !== 'id') break; + this.next(); // consume . + const prop = this.peek()!; + if (prop.t === 'id' && prop.v === 'map' && this.peek(1)?.t === '(') { + this.next(); // consume 'map' + this.next(); // consume '(' + item = this.applyMapToChildren(item); + if (this.peek()?.t === ')') this.next(); + } else if (prop.t === 'id' && prop.v in DOT_STYLE) { + this.next(); + item = addStyle(item, DOT_STYLE[prop.v]); + } else { + break; + } + } else break; + } + return item; + } + + applyMapToChildren(item: VN): VN { + if (item.t !== 'e') return item; + const ops: Array<{ type: 'class'; cls: string } | { type: 'style'; style: string }> = []; + while (this.peek() && this.peek()!.t !== ')') { + if (this.peek()?.t === '+') { + this.next(); + const id = this.peek(); + if (id?.t === 'id') { + if (id.v in PAD && this.peek(1)?.t === 'num') { + this.next(); + const n = (this.next() as { v: number }).v; + ops.push({ type: 'style', style: PAD[id.v](n * 0.125) }); + } else { + this.next(); + ops.push({ type: 'class', cls: id.v }); + } + } + } else if (this.peek()?.t === '.') { + this.next(); + const prop = this.peek(); + if (prop?.t === 'id' && prop.v in DOT_STYLE) { + this.next(); + ops.push({ type: 'style', style: DOT_STYLE[prop.v] }); + } + } else { + this.next(); + } + } + const newCh = item.ch.map(child => { + let c = child; + for (const op of ops) { + if (op.type === 'class') c = addClassName(c, op.cls); + else c = addStyle(c, op.style); + } + return c; + }); + return { ...item, ch: newCh }; + } + + parseFractionRaw(): number { + const n = this.next(); + if (!n || n.t !== 'num') return 0; + let val = n.v; + if (this.peek()?.t === '%') { this.next(); return val / 100; } + if (this.peek()?.t === '/') { + this.next(); + const d = this.next(); + if (d && d.t === 'num' && d.v !== 0) val = val / d.v; + } + return val; + } +} + +// ============================================================ +// INode → VNode +// ============================================================ + +function iNodeToVN(node: INode): VN { + const tks = tokenize(node.content); + const indentCh = node.children.map(c => iNodeToVN(c)); + const p = new TkParser(tks, node.content); + return p.parseLine(indentCh); +} + +function mergeIntoBlock(item: VN): VN { + if (item.t === 'e' && item.tag === 'div') { + const cur = item.props.className || ''; + const cls = cur ? 'block ' + cur : 'block'; + return { ...item, props: { ...item.props, className: cls } }; + } + if (item.t === 't') return vblock([item]); + return item; +} + +// ============================================================ +// Main Parse +// ============================================================ + +export function parse(src: string): VN { + const preprocessed = joinMultiLineParens(src); + const tree = buildIndentTree(preprocessed); + + const classDefs: INode[] = []; + const content: INode[] = []; + for (const node of tree) { + if (node.content.startsWith('class ')) classDefs.push(node); + else content.push(node); + } + processClassDefs(classDefs); + + if (content.length === 0) return vblock([]); + if (content.length === 1) return iNodeToVN(content[0]); + return vblock(content.map(n => iNodeToVN(n))); +} + +// ============================================================ +// Expose API +// ============================================================ + +export { ve, vt, vblock, vspan, patch, createDom }; + +(window as any).Ether = { + signal, effect, batch, mount, parse, + ve, vt, vblock, vspan, addClassName, addStyle, addWidth, +}; + +// ============================================================ +// Bootstrap +// ============================================================ + +function boot() { + const container = document.getElementById('root'); + if (!container) return; + mount(container, () => parse(source)); +} + +boot(); diff --git a/Ether/.html/manifest.json b/@ether/.html/manifest.json similarity index 100% rename from Ether/.html/manifest.json rename to @ether/.html/manifest.json diff --git a/Ether/.html/package-lock.json b/@ether/.html/package-lock.json similarity index 100% rename from Ether/.html/package-lock.json rename to @ether/.html/package-lock.json diff --git a/Ether/.html/package.json b/@ether/.html/package.json similarity index 76% rename from Ether/.html/package.json rename to @ether/.html/package.json index da3f9f3d..22409870 100644 --- a/Ether/.html/package.json +++ b/@ether/.html/package.json @@ -1,8 +1,10 @@ { "scripts": { - "images:copy": "HTMLDIR=$(dirname \"$npm_package_json\") && mkdir -p \"$HTMLDIR/public/images\" && cp \"$HTMLDIR\"/../*.svg \"$HTMLDIR\"/../*.png \"$HTMLDIR/public/images/\"", - "icons:generate": "HTMLDIR=$(dirname \"$npm_package_json\") && npm run images:copy && python3 src-tauri/icons/generate.py \"$HTMLDIR/public/images/E.png\"", + "images:copy": "HTMLDIR=$(dirname \"$npm_package_json\") && mkdir -p \"$HTMLDIR/public/images/avatar\" && cp \"$HTMLDIR\"/../avatar/*.svg \"$HTMLDIR\"/../avatar/*.png \"$HTMLDIR/public/images/avatar/\"", + "icons:generate": "HTMLDIR=$(dirname \"$npm_package_json\") && npm run images:copy && python3 src-tauri/icons/generate.py \"$HTMLDIR/public/images/avatar/2d-square.png\"", "install": "HTMLDIR=$(dirname \"$npm_package_json\") && cd \"$HTMLDIR/src-tauri\" && [ -z \"$CF_PAGES\" ] && cargo fetch || true", + "copy:ether": "HTMLDIR=$(dirname \"$npm_package_json\") && rsync -a --exclude='.html' --exclude='node_modules' --exclude='__pycache__' --exclude='.git' --exclude='target' \"$HTMLDIR/../\" \"$HTMLDIR/dist/@ether/\"", + "start": "npx vite", "tauri": "tauri", "tauri:dev": "tauri dev", "build": "npm run build:linux && npm run build:windows && npm run build:android", diff --git a/@ether/.html/public/icons/3D_Modeling.svg b/@ether/.html/public/icons/3D_Modeling.svg new file mode 100644 index 00000000..7eeebb07 --- /dev/null +++ b/@ether/.html/public/icons/3D_Modeling.svg @@ -0,0 +1,8 @@ + + + + diff --git a/@ether/.html/public/icons/Astronomy.svg b/@ether/.html/public/icons/Astronomy.svg new file mode 100644 index 00000000..3041baef --- /dev/null +++ b/@ether/.html/public/icons/Astronomy.svg @@ -0,0 +1,8 @@ + + + + + diff --git a/@ether/.html/public/icons/Biology.svg b/@ether/.html/public/icons/Biology.svg new file mode 100644 index 00000000..9c3f85c0 --- /dev/null +++ b/@ether/.html/public/icons/Biology.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/@ether/.html/public/icons/Chemistry.svg b/@ether/.html/public/icons/Chemistry.svg new file mode 100644 index 00000000..9adc9da4 --- /dev/null +++ b/@ether/.html/public/icons/Chemistry.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/@ether/.html/public/icons/Drawing.svg b/@ether/.html/public/icons/Drawing.svg new file mode 100644 index 00000000..02f54afc --- /dev/null +++ b/@ether/.html/public/icons/Drawing.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/@ether/.html/public/icons/Hacking.svg b/@ether/.html/public/icons/Hacking.svg new file mode 100644 index 00000000..5ae308a8 --- /dev/null +++ b/@ether/.html/public/icons/Hacking.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/@ether/.html/public/icons/Mathematics.svg b/@ether/.html/public/icons/Mathematics.svg new file mode 100644 index 00000000..226a7a85 --- /dev/null +++ b/@ether/.html/public/icons/Mathematics.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/@ether/.html/public/icons/Music.svg b/@ether/.html/public/icons/Music.svg new file mode 100644 index 00000000..d22cb6cc --- /dev/null +++ b/@ether/.html/public/icons/Music.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/@ether/.html/public/icons/Physics.svg b/@ether/.html/public/icons/Physics.svg new file mode 100644 index 00000000..9640e8b7 --- /dev/null +++ b/@ether/.html/public/icons/Physics.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/@ether/.html/public/icons/Programming.svg b/@ether/.html/public/icons/Programming.svg new file mode 100644 index 00000000..0bbdf31e --- /dev/null +++ b/@ether/.html/public/icons/Programming.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/@ether/.html/public/sandbox.html b/@ether/.html/public/sandbox.html new file mode 100644 index 00000000..5798fdef --- /dev/null +++ b/@ether/.html/public/sandbox.html @@ -0,0 +1,67 @@ + + + + + diff --git a/Ether/.html/src-tauri/Cargo.lock b/@ether/.html/src-tauri/Cargo.lock similarity index 99% rename from Ether/.html/src-tauri/Cargo.lock rename to @ether/.html/src-tauri/Cargo.lock index 7deda82a..d49315a2 100644 --- a/Ether/.html/src-tauri/Cargo.lock +++ b/@ether/.html/src-tauri/Cargo.lock @@ -47,6 +47,12 @@ version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + [[package]] name = "atk" version = "0.18.2" @@ -297,6 +303,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + [[package]] name = "combine" version = "4.6.7" @@ -663,6 +675,7 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tiny_http", ] [[package]] @@ -1205,6 +1218,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.8.1" @@ -3398,6 +3417,18 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + [[package]] name = "tinystr" version = "0.8.2" diff --git a/Ether/.html/src-tauri/Cargo.toml b/@ether/.html/src-tauri/Cargo.toml similarity index 94% rename from Ether/.html/src-tauri/Cargo.toml rename to @ether/.html/src-tauri/Cargo.toml index b5bd7910..4152381e 100644 --- a/Ether/.html/src-tauri/Cargo.toml +++ b/@ether/.html/src-tauri/Cargo.toml @@ -15,3 +15,4 @@ tauri-build = { version = "2", features = [] } tauri = { version = "2", features = [] } serde = { version = "1", features = ["derive"] } serde_json = "1" +tiny_http = "0.12" diff --git a/Ether/.html/src-tauri/build.rs b/@ether/.html/src-tauri/build.rs similarity index 100% rename from Ether/.html/src-tauri/build.rs rename to @ether/.html/src-tauri/build.rs diff --git a/Ether/.html/src-tauri/capabilities/default.json b/@ether/.html/src-tauri/capabilities/default.json similarity index 100% rename from Ether/.html/src-tauri/capabilities/default.json rename to @ether/.html/src-tauri/capabilities/default.json diff --git a/Ether/.html/src-tauri/icons/128x128.png b/@ether/.html/src-tauri/icons/128x128.png similarity index 100% rename from Ether/.html/src-tauri/icons/128x128.png rename to @ether/.html/src-tauri/icons/128x128.png diff --git a/Ether/.html/src-tauri/icons/128x128@2x.png b/@ether/.html/src-tauri/icons/128x128@2x.png similarity index 100% rename from Ether/.html/src-tauri/icons/128x128@2x.png rename to @ether/.html/src-tauri/icons/128x128@2x.png diff --git a/Ether/.html/src-tauri/icons/32x32.png b/@ether/.html/src-tauri/icons/32x32.png similarity index 100% rename from Ether/.html/src-tauri/icons/32x32.png rename to @ether/.html/src-tauri/icons/32x32.png diff --git a/Ether/.html/src-tauri/icons/generate.py b/@ether/.html/src-tauri/icons/generate.py similarity index 100% rename from Ether/.html/src-tauri/icons/generate.py rename to @ether/.html/src-tauri/icons/generate.py diff --git a/Ether/.html/src-tauri/icons/icon.icns b/@ether/.html/src-tauri/icons/icon.icns similarity index 100% rename from Ether/.html/src-tauri/icons/icon.icns rename to @ether/.html/src-tauri/icons/icon.icns diff --git a/Ether/.html/src-tauri/icons/icon.ico b/@ether/.html/src-tauri/icons/icon.ico similarity index 100% rename from Ether/.html/src-tauri/icons/icon.ico rename to @ether/.html/src-tauri/icons/icon.ico diff --git a/@ether/.html/src-tauri/src/lib.rs b/@ether/.html/src-tauri/src/lib.rs new file mode 100644 index 00000000..84593950 --- /dev/null +++ b/@ether/.html/src-tauri/src/lib.rs @@ -0,0 +1,139 @@ +use serde::Serialize; +use std::path::{Path, PathBuf}; + +/// Resolve the @ether/ root directory. +/// - Desktop: ETHER_ROOT env var, or the parent of the `.html/` directory +/// - Android: Tauri resource dir + "@ether" +fn ether_root(app: &tauri::AppHandle) -> Result { + // 1. Explicit env var + if let Ok(root) = std::env::var("ETHER_ROOT") { + let p = PathBuf::from(root); + if p.is_dir() { + return Ok(p); + } + } + + // 2. Relative to the executable: exe is in src-tauri/target/*/ether, + // @ether/ root is ../../.. (i.e. the @ether/ directory itself). + // In dev, exe dir is src-tauri/target/debug/, @ether is ../../../ + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + // Walk up looking for an @ether/ directory marker (.ray/ or library/) + let mut candidate = dir.to_path_buf(); + for _ in 0..6 { + if candidate.join(".ray").is_dir() || candidate.join("library").is_dir() { + return Ok(candidate); + } + if !candidate.pop() { + break; + } + } + } + } + + // 3. Tauri resource directory (Android bundled assets) + let _ = app; // use app handle for resource resolution in the future + + Err("Could not determine @ether root directory. Set ETHER_ROOT env var.".into()) +} + +/// Validate and resolve a relative path against the @ether root. +/// Rejects path traversal attempts (.. segments). +fn resolve_safe(root: &Path, relative: &str) -> Result { + // Reject .. segments + for segment in relative.split('/') { + if segment == ".." { + return Err("Path traversal not allowed".into()); + } + } + let target = root.join(relative); + // Canonicalize both to ensure the resolved path is under root + let canon_root = root + .canonicalize() + .map_err(|e| format!("Cannot canonicalize root: {}", e))?; + let canon_target = target + .canonicalize() + .map_err(|e| format!("Path not found: {}", e))?; + if !canon_target.starts_with(&canon_root) { + return Err("Path escapes @ether root".into()); + } + Ok(canon_target) +} + +#[derive(Serialize)] +struct DirEntry { + name: String, + is_directory: bool, + size: Option, +} + +#[tauri::command] +fn list_directory(app: tauri::AppHandle, path: String) -> Result, String> { + let root = ether_root(&app)?; + let target = if path.is_empty() { + root.canonicalize().map_err(|e| format!("Cannot canonicalize root: {}", e))? + } else { + resolve_safe(&root, &path)? + }; + + if !target.is_dir() { + return Err(format!("Not a directory: {}", path)); + } + + let mut entries = Vec::new(); + let read_dir = std::fs::read_dir(&target).map_err(|e| format!("Cannot read directory: {}", e))?; + for entry in read_dir { + let entry = entry.map_err(|e| format!("Error reading entry: {}", e))?; + let metadata = entry.metadata().map_err(|e| format!("Error reading metadata: {}", e))?; + entries.push(DirEntry { + name: entry.file_name().to_string_lossy().into_owned(), + is_directory: metadata.is_dir(), + size: if metadata.is_file() { + Some(metadata.len()) + } else { + None + }, + }); + } + entries.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(entries) +} + +#[tauri::command] +fn read_file(app: tauri::AppHandle, path: String) -> Result { + let root = ether_root(&app)?; + let target = resolve_safe(&root, &path)?; + + if !target.is_file() { + return Err(format!("Not a file: {}", path)); + } + + std::fs::read_to_string(&target).map_err(|e| format!("Cannot read file: {}", e)) +} + +#[tauri::command] +fn file_exists(app: tauri::AppHandle, path: String) -> bool { + let root = match ether_root(&app) { + Ok(r) => r, + Err(_) => return false, + }; + if path.is_empty() { + return root.is_dir(); + } + match resolve_safe(&root, &path) { + Ok(target) => target.exists(), + Err(_) => false, + } +} + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + .invoke_handler(tauri::generate_handler![ + list_directory, + read_file, + file_exists + ]) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} diff --git a/@ether/.html/src-tauri/src/main.rs b/@ether/.html/src-tauri/src/main.rs new file mode 100644 index 00000000..25dbd284 --- /dev/null +++ b/@ether/.html/src-tauri/src/main.rs @@ -0,0 +1,67 @@ +// Prevents additional console window on Windows in release, DO NOT REMOVE!! +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +mod server; + +fn main() { + let mut args = std::env::args().skip(1); + match args.next().as_deref() { + Some("version" | "%") => { + #[cfg(windows)] + { + extern "system" { fn AttachConsole(id: u32) -> i32; } + unsafe { AttachConsole(u32::MAX); } + } + println!("Ether {}", env!("CARGO_PKG_VERSION")); + } + Some("serve") => { + let port: u16 = args + .next() + .and_then(|p| p.parse().ok()) + .unwrap_or(8421); + + // @ether root: ETHER_ROOT env, or walk up from exe to find it + let ether_root = std::env::var("ETHER_ROOT") + .map(std::path::PathBuf::from) + .ok() + .or_else(|| { + let exe = std::env::current_exe().ok()?; + let mut dir = exe.parent()?.to_path_buf(); + for _ in 0..6 { + if dir.join(".ray").is_dir() || dir.join("library").is_dir() { + return Some(dir); + } + dir.pop(); + } + None + }) + .unwrap_or_else(|| { + eprintln!("Cannot determine @ether root. Set ETHER_ROOT env var."); + std::process::exit(1); + }); + + // Dist root: next to the exe, or ETHER_DIST env + let dist_root = std::env::var("ETHER_DIST") + .map(std::path::PathBuf::from) + .ok() + .or_else(|| { + let exe = std::env::current_exe().ok()?; + let dir = exe.parent()?; + let candidate = dir.join("dist"); + if candidate.is_dir() { + return Some(candidate); + } + // Also check ../dist (common in dev) + let candidate = dir.parent()?.join("dist"); + if candidate.is_dir() { + return Some(candidate); + } + None + }) + .unwrap_or_else(|| std::path::PathBuf::from("dist")); + + server::serve(ether_root, dist_root, port); + } + _ => ether_lib::run(), + } +} diff --git a/@ether/.html/src-tauri/src/server.rs b/@ether/.html/src-tauri/src/server.rs new file mode 100644 index 00000000..c15b9a04 --- /dev/null +++ b/@ether/.html/src-tauri/src/server.rs @@ -0,0 +1,184 @@ +//! Self-hosting HTTP server for Ether. +//! +//! Serves both the SPA (`dist/`) and the `@ether/` filesystem on a single port. +//! +//! Routing: +//! 1. `/**/...` → strip `/**/` prefix, resolve against `ether_root` +//! - File → raw content with MIME type +//! - Directory → JSON listing +//! - Not found → 404 +//! 2. `/assets/...`, known static extensions → serve from `dist_root` +//! 3. Everything else → `dist_root/index.html` (SPA fallback) + +use std::fs; +use std::io::Cursor; +use std::path::{Path, PathBuf}; + +/// Start the self-hosting HTTP server. +pub fn serve(ether_root: PathBuf, dist_root: PathBuf, port: u16) { + let addr = format!("0.0.0.0:{}", port); + let server = tiny_http::Server::http(&addr).unwrap_or_else(|e| { + eprintln!("Failed to bind to {}: {}", addr, e); + std::process::exit(1); + }); + eprintln!("Ether server listening on http://0.0.0.0:{}", port); + eprintln!(" Ether root: {}", ether_root.display()); + eprintln!(" Dist root: {}", dist_root.display()); + + for request in server.incoming_requests() { + let url = request.url().to_string(); + let response = handle_request(&url, ðer_root, &dist_root); + let _ = request.respond(response); + } +} + +fn handle_request( + url: &str, + ether_root: &Path, + dist_root: &Path, +) -> tiny_http::Response>> { + // Strip query string + let path = url.split('?').next().unwrap_or(url); + + // 1. /**/ filesystem routes + if let Some(rest) = path.strip_prefix("/**/") { + return handle_ether_path(rest, ether_root); + } + + // 2. Static assets from dist/ + let dist_path = dist_root.join(path.trim_start_matches('/')); + if dist_path.is_file() { + return serve_file(&dist_path); + } + + // 3. SPA fallback + let index = dist_root.join("index.html"); + if index.is_file() { + return serve_file(&index); + } + + response_text(404, "Not Found") +} + +fn handle_ether_path( + relative: &str, + ether_root: &Path, +) -> tiny_http::Response>> { + // Path traversal protection + for segment in relative.split('/') { + if segment == ".." { + return response_text(403, "Path traversal not allowed"); + } + } + + let target = ether_root.join(relative); + + // Canonicalize to prevent symlink escapes + if let Ok(canon_root) = ether_root.canonicalize() { + if let Ok(canon_target) = target.canonicalize() { + if !canon_target.starts_with(&canon_root) { + return response_text(403, "Path escapes root"); + } + } + } + + if target.is_file() { + serve_file(&target) + } else if target.is_dir() { + serve_dir_json(&target) + } else { + response_text(404, "Not Found") + } +} + +fn make_response( + status: u16, + content_type: &str, + data: Vec, +) -> tiny_http::Response>> { + let len = data.len(); + tiny_http::Response::new( + tiny_http::StatusCode(status), + vec![ + tiny_http::Header::from_bytes("Content-Type", content_type).unwrap(), + tiny_http::Header::from_bytes("Content-Length", len.to_string()).unwrap(), + tiny_http::Header::from_bytes("Access-Control-Allow-Origin", "*").unwrap(), + ], + Cursor::new(data), + Some(len), + None, + ) +} + +fn serve_file(path: &Path) -> tiny_http::Response>> { + match fs::read(path) { + Ok(data) => make_response(200, mime_for_path(path), data), + Err(_) => response_text(500, "Failed to read file"), + } +} + +fn serve_dir_json(path: &Path) -> tiny_http::Response>> { + let read_dir = match fs::read_dir(path) { + Ok(rd) => rd, + Err(_) => return response_text(500, "Failed to read directory"), + }; + + let mut entries: Vec = Vec::new(); + for entry in read_dir.flatten() { + let meta = match entry.metadata() { + Ok(m) => m, + Err(_) => continue, + }; + let mut obj = serde_json::Map::new(); + obj.insert( + "name".into(), + serde_json::Value::String(entry.file_name().to_string_lossy().into_owned()), + ); + obj.insert("isDirectory".into(), serde_json::Value::Bool(meta.is_dir())); + if meta.is_file() { + obj.insert( + "size".into(), + serde_json::Value::Number(meta.len().into()), + ); + } + entries.push(serde_json::Value::Object(obj)); + } + + // Sort by name + entries.sort_by(|a, b| { + let na = a.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let nb = b.get("name").and_then(|v| v.as_str()).unwrap_or(""); + na.cmp(nb) + }); + + let json = serde_json::to_string(&entries).unwrap_or_else(|_| "[]".into()); + make_response(200, "application/json", json.into_bytes()) +} + +fn response_text(status: u16, body: &str) -> tiny_http::Response>> { + make_response(status, "text/plain", body.as_bytes().to_vec()) +} + +fn mime_for_path(path: &Path) -> &'static str { + match path.extension().and_then(|e| e.to_str()) { + Some("html") => "text/html; charset=utf-8", + Some("css") => "text/css; charset=utf-8", + Some("js" | "mjs") => "application/javascript; charset=utf-8", + Some("json") => "application/json; charset=utf-8", + Some("svg") => "image/svg+xml", + Some("png") => "image/png", + Some("jpg" | "jpeg") => "image/jpeg", + Some("gif") => "image/gif", + Some("webp") => "image/webp", + Some("ico") => "image/x-icon", + Some("woff") => "font/woff", + Some("woff2") => "font/woff2", + Some("ttf") => "font/ttf", + Some("otf") => "font/otf", + Some("wasm") => "application/wasm", + Some("txt" | "md" | "ray" | "ts" | "rs" | "py" | "sh" | "toml" | "yaml" | "yml" | "log") => { + "text/plain; charset=utf-8" + } + _ => "application/octet-stream", + } +} diff --git a/Ether/.html/src-tauri/tauri.conf.json b/@ether/.html/src-tauri/tauri.conf.json similarity index 97% rename from Ether/.html/src-tauri/tauri.conf.json rename to @ether/.html/src-tauri/tauri.conf.json index 54b2b6f2..0352e239 100644 --- a/Ether/.html/src-tauri/tauri.conf.json +++ b/@ether/.html/src-tauri/tauri.conf.json @@ -7,7 +7,7 @@ "frontendDist": "../dist", "devUrl": "http://localhost:5173", "beforeDevCommand": "npm run icons:generate && npx vite", - "beforeBuildCommand": "npm run icons:generate && npx vite build" + "beforeBuildCommand": "npm run icons:generate && npx vite build && npm run copy:ether" }, "app": { "withGlobalTauri": false, diff --git a/Ether/.html/tsconfig.json b/@ether/.html/tsconfig.json similarity index 93% rename from Ether/.html/tsconfig.json rename to @ether/.html/tsconfig.json index 53c4235e..e6b6c1ff 100755 --- a/Ether/.html/tsconfig.json +++ b/@ether/.html/tsconfig.json @@ -21,6 +21,8 @@ }, "include": [ "src", - "index.ts" + "index.ts", + "env.d.ts", + "UI" ] } \ No newline at end of file diff --git a/@ether/.html/vite.config.mjs b/@ether/.html/vite.config.mjs new file mode 100644 index 00000000..137368a8 --- /dev/null +++ b/@ether/.html/vite.config.mjs @@ -0,0 +1,123 @@ +import {defineConfig} from 'vite'; +import path from 'path'; +import fs from 'fs'; + +// Ether filesystem plugin — intercepts /**/ paths during dev to serve +// the real filesystem, mirroring what the production server does. +// +// URL mapping: +// /**/ → .ether/ (metadata root) +// /**/@ether/... → @ether/... (@ether user's repository) +function etherFsPlugin() { + const projectRoot = path.resolve(__dirname, '../..'); + + return { + name: 'ether-fs', + configureServer(server) { + server.middlewares.use((req, res, next) => { + // Decode URI and match /**/... + const decoded = decodeURIComponent(req.url || ''); + const match = decoded.match(/^\/\*\*\/(.*)/); + if (!match) return next(); + + const relative = match[1].replace(/\/$/, '') // strip trailing slash + .split('/').map(seg => seg.startsWith('!') ? seg.slice(1) : seg).join('/'); // strip ! escape prefix + + // Route: empty path → .ether/ + // Route: @ether/... → @ether/... + // Route: @/... → .ether/... (local player's root) + let fsPath; + if (relative === '' || relative === '.') { + fsPath = path.resolve(projectRoot, '.ether'); + } else if (relative === '@ether') { + fsPath = path.resolve(projectRoot, '@ether'); + } else if (relative.startsWith('@ether/')) { + fsPath = path.resolve(projectRoot, '@ether', relative.slice('@ether/'.length)); + } else if (relative.match(/^@[^/]+$/)) { + // @ with no subpath → .ether/ + fsPath = path.resolve(projectRoot, '.ether'); + } else if (relative.match(/^@[^/]+\//)) { + // @/subpath → .ether/subpath + const subpath = relative.replace(/^@[^/]+\//, ''); + fsPath = path.resolve(projectRoot, '.ether', subpath); + } else { + fsPath = path.resolve(projectRoot, relative); + } + + // Path traversal protection + if (!fsPath.startsWith(projectRoot)) { + res.statusCode = 403; + res.end('Path traversal not allowed'); + return; + } + + // CORS headers + res.setHeader('Access-Control-Allow-Origin', '*'); + + try { + const stat = fs.statSync(fsPath); + + if (stat.isDirectory()) { + // Return JSON directory listing + const entries = fs.readdirSync(fsPath, { withFileTypes: true }); + const json = entries.map(e => ({ + name: e.name, + isDirectory: e.isDirectory(), + ...(e.isFile() ? { size: fs.statSync(path.join(fsPath, e.name)).size } : {}), + })); + json.sort((a, b) => a.name.localeCompare(b.name)); + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify(json)); + } else if (stat.isFile()) { + // Serve raw file with MIME type + const ext = path.extname(fsPath).toLowerCase(); + const mimeTypes = { + '.html': 'text/html; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.js': 'application/javascript; charset=utf-8', + '.mjs': 'application/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.ico': 'image/x-icon', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.txt': 'text/plain; charset=utf-8', + '.md': 'text/plain; charset=utf-8', + '.ray': 'text/plain; charset=utf-8', + '.ts': 'text/plain; charset=utf-8', + '.rs': 'text/plain; charset=utf-8', + '.py': 'text/plain; charset=utf-8', + '.sh': 'text/plain; charset=utf-8', + '.toml': 'text/plain; charset=utf-8', + '.yaml': 'text/plain; charset=utf-8', + '.yml': 'text/plain; charset=utf-8', + }; + res.setHeader('Content-Type', mimeTypes[ext] || 'application/octet-stream'); + const data = fs.readFileSync(fsPath); + res.end(data); + } else { + res.statusCode = 404; + res.end('Not Found'); + } + } catch (e) { + res.statusCode = 404; + res.end('Not Found'); + } + }); + }, + }; +} + +export default defineConfig({ + base: '/', + appType: 'spa', + plugins: [etherFsPlugin()], + build: { + outDir: './dist' + }, +}); diff --git a/@ether/.html/wrangler.toml b/@ether/.html/wrangler.toml new file mode 100644 index 00000000..8c2ce35a --- /dev/null +++ b/@ether/.html/wrangler.toml @@ -0,0 +1,3 @@ +[assets] +directory = "./dist" +not_found_handling = "single-page-application" diff --git a/Ether/.intellij/.gitignore b/@ether/.intellij/.gitignore similarity index 100% rename from Ether/.intellij/.gitignore rename to @ether/.intellij/.gitignore diff --git a/Ether/.intellij/README.md b/@ether/.intellij/README.md similarity index 100% rename from Ether/.intellij/README.md rename to @ether/.intellij/README.md diff --git a/Ether/.intellij/build.gradle.kts b/@ether/.intellij/build.gradle.kts similarity index 100% rename from Ether/.intellij/build.gradle.kts rename to @ether/.intellij/build.gradle.kts diff --git a/Ether/.intellij/gradle.properties b/@ether/.intellij/gradle.properties similarity index 100% rename from Ether/.intellij/gradle.properties rename to @ether/.intellij/gradle.properties diff --git a/Ether/.intellij/gradle/wrapper/gradle-wrapper.jar b/@ether/.intellij/gradle/wrapper/gradle-wrapper.jar similarity index 100% rename from Ether/.intellij/gradle/wrapper/gradle-wrapper.jar rename to @ether/.intellij/gradle/wrapper/gradle-wrapper.jar diff --git a/Ether/.intellij/gradle/wrapper/gradle-wrapper.properties b/@ether/.intellij/gradle/wrapper/gradle-wrapper.properties similarity index 100% rename from Ether/.intellij/gradle/wrapper/gradle-wrapper.properties rename to @ether/.intellij/gradle/wrapper/gradle-wrapper.properties diff --git a/Ether/.intellij/gradlew b/@ether/.intellij/gradlew similarity index 100% rename from Ether/.intellij/gradlew rename to @ether/.intellij/gradlew diff --git a/Ether/.intellij/gradlew.bat b/@ether/.intellij/gradlew.bat similarity index 100% rename from Ether/.intellij/gradlew.bat rename to @ether/.intellij/gradlew.bat diff --git a/Ether/.intellij/settings.gradle.kts b/@ether/.intellij/settings.gradle.kts similarity index 100% rename from Ether/.intellij/settings.gradle.kts rename to @ether/.intellij/settings.gradle.kts diff --git a/Ether/.intellij/src/main/gen/com/orbitmines/ether/Ray/Txt/Parser.java b/@ether/.intellij/src/main/gen/com/orbitmines/ether/Ray/Txt/Parser.java similarity index 100% rename from Ether/.intellij/src/main/gen/com/orbitmines/ether/Ray/Txt/Parser.java rename to @ether/.intellij/src/main/gen/com/orbitmines/ether/Ray/Txt/Parser.java diff --git a/Ether/.intellij/src/main/gen/com/orbitmines/ether/psi/RayExpr.java b/@ether/.intellij/src/main/gen/com/orbitmines/ether/psi/RayExpr.java similarity index 100% rename from Ether/.intellij/src/main/gen/com/orbitmines/ether/psi/RayExpr.java rename to @ether/.intellij/src/main/gen/com/orbitmines/ether/psi/RayExpr.java diff --git a/Ether/.intellij/src/main/gen/com/orbitmines/ether/psi/RayIdentifier.java b/@ether/.intellij/src/main/gen/com/orbitmines/ether/psi/RayIdentifier.java similarity index 100% rename from Ether/.intellij/src/main/gen/com/orbitmines/ether/psi/RayIdentifier.java rename to @ether/.intellij/src/main/gen/com/orbitmines/ether/psi/RayIdentifier.java diff --git a/Ether/.intellij/src/main/gen/com/orbitmines/ether/psi/RayKeyword.java b/@ether/.intellij/src/main/gen/com/orbitmines/ether/psi/RayKeyword.java similarity index 100% rename from Ether/.intellij/src/main/gen/com/orbitmines/ether/psi/RayKeyword.java rename to @ether/.intellij/src/main/gen/com/orbitmines/ether/psi/RayKeyword.java diff --git a/Ether/.intellij/src/main/gen/com/orbitmines/ether/psi/RayNumber.java b/@ether/.intellij/src/main/gen/com/orbitmines/ether/psi/RayNumber.java similarity index 100% rename from Ether/.intellij/src/main/gen/com/orbitmines/ether/psi/RayNumber.java rename to @ether/.intellij/src/main/gen/com/orbitmines/ether/psi/RayNumber.java diff --git a/Ether/.intellij/src/main/gen/com/orbitmines/ether/psi/RayStatement.java b/@ether/.intellij/src/main/gen/com/orbitmines/ether/psi/RayStatement.java similarity index 100% rename from Ether/.intellij/src/main/gen/com/orbitmines/ether/psi/RayStatement.java rename to @ether/.intellij/src/main/gen/com/orbitmines/ether/psi/RayStatement.java diff --git a/Ether/.intellij/src/main/gen/com/orbitmines/ether/psi/RayVisitor.java b/@ether/.intellij/src/main/gen/com/orbitmines/ether/psi/RayVisitor.java similarity index 100% rename from Ether/.intellij/src/main/gen/com/orbitmines/ether/psi/RayVisitor.java rename to @ether/.intellij/src/main/gen/com/orbitmines/ether/psi/RayVisitor.java diff --git a/Ether/.intellij/src/main/gen/generated/GeneratedTypes.java b/@ether/.intellij/src/main/gen/generated/GeneratedTypes.java similarity index 100% rename from Ether/.intellij/src/main/gen/generated/GeneratedTypes.java rename to @ether/.intellij/src/main/gen/generated/GeneratedTypes.java diff --git a/Ether/.intellij/src/main/gen/generated/psi/impl/RayExprImpl.java b/@ether/.intellij/src/main/gen/generated/psi/impl/RayExprImpl.java similarity index 100% rename from Ether/.intellij/src/main/gen/generated/psi/impl/RayExprImpl.java rename to @ether/.intellij/src/main/gen/generated/psi/impl/RayExprImpl.java diff --git a/Ether/.intellij/src/main/gen/generated/psi/impl/RayIdentifierImpl.java b/@ether/.intellij/src/main/gen/generated/psi/impl/RayIdentifierImpl.java similarity index 100% rename from Ether/.intellij/src/main/gen/generated/psi/impl/RayIdentifierImpl.java rename to @ether/.intellij/src/main/gen/generated/psi/impl/RayIdentifierImpl.java diff --git a/Ether/.intellij/src/main/gen/generated/psi/impl/RayKeywordImpl.java b/@ether/.intellij/src/main/gen/generated/psi/impl/RayKeywordImpl.java similarity index 100% rename from Ether/.intellij/src/main/gen/generated/psi/impl/RayKeywordImpl.java rename to @ether/.intellij/src/main/gen/generated/psi/impl/RayKeywordImpl.java diff --git a/Ether/.intellij/src/main/gen/generated/psi/impl/RayNumberImpl.java b/@ether/.intellij/src/main/gen/generated/psi/impl/RayNumberImpl.java similarity index 100% rename from Ether/.intellij/src/main/gen/generated/psi/impl/RayNumberImpl.java rename to @ether/.intellij/src/main/gen/generated/psi/impl/RayNumberImpl.java diff --git a/Ether/.intellij/src/main/gen/generated/psi/impl/RayStatementImpl.java b/@ether/.intellij/src/main/gen/generated/psi/impl/RayStatementImpl.java similarity index 100% rename from Ether/.intellij/src/main/gen/generated/psi/impl/RayStatementImpl.java rename to @ether/.intellij/src/main/gen/generated/psi/impl/RayStatementImpl.java diff --git a/Ether/.intellij/src/main/grammar/Ray2.kt b/@ether/.intellij/src/main/grammar/Ray2.kt similarity index 100% rename from Ether/.intellij/src/main/grammar/Ray2.kt rename to @ether/.intellij/src/main/grammar/Ray2.kt diff --git a/Ether/.intellij/src/main/grammar/ray.txt.bnf b/@ether/.intellij/src/main/grammar/ray.txt.bnf similarity index 100% rename from Ether/.intellij/src/main/grammar/ray.txt.bnf rename to @ether/.intellij/src/main/grammar/ray.txt.bnf diff --git a/Ether/.intellij/src/main/kotlin/com/orbitmines/ether/Ray.kt b/@ether/.intellij/src/main/kotlin/com/orbitmines/ether/Ray.kt similarity index 100% rename from Ether/.intellij/src/main/kotlin/com/orbitmines/ether/Ray.kt rename to @ether/.intellij/src/main/kotlin/com/orbitmines/ether/Ray.kt diff --git a/Ether/.intellij/src/main/resources/META-INF/plugin.xml b/@ether/.intellij/src/main/resources/META-INF/plugin.xml similarity index 100% rename from Ether/.intellij/src/main/resources/META-INF/plugin.xml rename to @ether/.intellij/src/main/resources/META-INF/plugin.xml diff --git a/Ether/.intellij/src/main/resources/META-INF/pluginIcon.svg b/@ether/.intellij/src/main/resources/META-INF/pluginIcon.svg similarity index 100% rename from Ether/.intellij/src/main/resources/META-INF/pluginIcon.svg rename to @ether/.intellij/src/main/resources/META-INF/pluginIcon.svg diff --git a/Ether/.intellij/src/main/resources/icons/file.svg b/@ether/.intellij/src/main/resources/icons/file.svg similarity index 100% rename from Ether/.intellij/src/main/resources/icons/file.svg rename to @ether/.intellij/src/main/resources/icons/file.svg diff --git a/Ether/.intellij/src/main/resources/icons/file64x64.png b/@ether/.intellij/src/main/resources/icons/file64x64.png similarity index 100% rename from Ether/.intellij/src/main/resources/icons/file64x64.png rename to @ether/.intellij/src/main/resources/icons/file64x64.png diff --git a/Ether/.intellij/src/main/resources/messages/MyMessageBundle.properties b/@ether/.intellij/src/main/resources/messages/MyMessageBundle.properties similarity index 100% rename from Ether/.intellij/src/main/resources/messages/MyMessageBundle.properties rename to @ether/.intellij/src/main/resources/messages/MyMessageBundle.properties diff --git a/Ether/.ray/Accessor.ray b/@ether/.ray/Accessor.ray similarity index 100% rename from Ether/.ray/Accessor.ray rename to @ether/.ray/Accessor.ray diff --git a/Ether/.ray/Character.ray b/@ether/.ray/Character.ray similarity index 100% rename from Ether/.ray/Character.ray rename to @ether/.ray/Character.ray diff --git a/Ether/.ray/Compiler/Optimizations.ray b/@ether/.ray/Compiler/Optimizations.ray similarity index 100% rename from Ether/.ray/Compiler/Optimizations.ray rename to @ether/.ray/Compiler/Optimizations.ray diff --git a/Ether/.ray/Entity.ray b/@ether/.ray/Entity.ray similarity index 100% rename from Ether/.ray/Entity.ray rename to @ether/.ray/Entity.ray diff --git a/Ether/.ray/Feature/Choice.ray b/@ether/.ray/Feature/Choice.ray similarity index 100% rename from Ether/.ray/Feature/Choice.ray rename to @ether/.ray/Feature/Choice.ray diff --git a/Ether/.ray/Feature/IO.ray b/@ether/.ray/Feature/IO.ray similarity index 100% rename from Ether/.ray/Feature/IO.ray rename to @ether/.ray/Feature/IO.ray diff --git a/Ether/.ray/Feature/Network/HTTP.ray b/@ether/.ray/Feature/Network/HTTP.ray similarity index 100% rename from Ether/.ray/Feature/Network/HTTP.ray rename to @ether/.ray/Feature/Network/HTTP.ray diff --git a/Ether/.ray/Feature/Network/Network.ray b/@ether/.ray/Feature/Network/Network.ray similarity index 100% rename from Ether/.ray/Feature/Network/Network.ray rename to @ether/.ray/Feature/Network/Network.ray diff --git a/Ether/.ray/Feature/Proof.ray b/@ether/.ray/Feature/Proof.ray similarity index 100% rename from Ether/.ray/Feature/Proof.ray rename to @ether/.ray/Feature/Proof.ray diff --git a/Ether/.ray/Feature/Random.ray b/@ether/.ray/Feature/Random.ray similarity index 100% rename from Ether/.ray/Feature/Random.ray rename to @ether/.ray/Feature/Random.ray diff --git a/Ether/.ray/Feature/Transaction.ray b/@ether/.ray/Feature/Transaction.ray similarity index 100% rename from Ether/.ray/Feature/Transaction.ray rename to @ether/.ray/Feature/Transaction.ray diff --git a/Ether/.ray/Feature/UI/Chat.ray b/@ether/.ray/Feature/UI/Chat.ray similarity index 100% rename from Ether/.ray/Feature/UI/Chat.ray rename to @ether/.ray/Feature/UI/Chat.ray diff --git a/Ether/.ray/Feature/UI/Keyboard.ray b/@ether/.ray/Feature/UI/Keyboard.ray similarity index 100% rename from Ether/.ray/Feature/UI/Keyboard.ray rename to @ether/.ray/Feature/UI/Keyboard.ray diff --git a/Ether/.ray/History.ray b/@ether/.ray/History.ray similarity index 100% rename from Ether/.ray/History.ray rename to @ether/.ray/History.ray diff --git a/Ether/.ray/Item.ray b/@ether/.ray/Item.ray similarity index 100% rename from Ether/.ray/Item.ray rename to @ether/.ray/Item.ray diff --git a/Ether/.ray/Language/Binary/Base64.ray b/@ether/.ray/Language/Binary/Base64.ray similarity index 100% rename from Ether/.ray/Language/Binary/Base64.ray rename to @ether/.ray/Language/Binary/Base64.ray diff --git a/Ether/.ray/Language/Encoding.ray b/@ether/.ray/Language/Encoding.ray similarity index 100% rename from Ether/.ray/Language/Encoding.ray rename to @ether/.ray/Language/Encoding.ray diff --git a/Ether/.ray/Language/Node/JSON.ray b/@ether/.ray/Language/Node/JSON.ray similarity index 100% rename from Ether/.ray/Language/Node/JSON.ray rename to @ether/.ray/Language/Node/JSON.ray diff --git a/Ether/.ray/Language/Node/XML.ray b/@ether/.ray/Language/Node/XML.ray similarity index 100% rename from Ether/.ray/Language/Node/XML.ray rename to @ether/.ray/Language/Node/XML.ray diff --git a/Ether/.ray/Language/Node/YAML.ray b/@ether/.ray/Language/Node/YAML.ray similarity index 100% rename from Ether/.ray/Language/Node/YAML.ray rename to @ether/.ray/Language/Node/YAML.ray diff --git a/Ether/.ray/Language/Program/HTML.ray b/@ether/.ray/Language/Program/HTML.ray similarity index 100% rename from Ether/.ray/Language/Program/HTML.ray rename to @ether/.ray/Language/Program/HTML.ray diff --git a/Ether/.ray/Language/String/Regex.ray b/@ether/.ray/Language/String/Regex.ray similarity index 100% rename from Ether/.ray/Language/String/Regex.ray rename to @ether/.ray/Language/String/Regex.ray diff --git a/Ether/.ray/Language/String/String.ray b/@ether/.ray/Language/String/String.ray similarity index 100% rename from Ether/.ray/Language/String/String.ray rename to @ether/.ray/Language/String/String.ray diff --git a/Ether/.ray/Language/String/Unicode.ray b/@ether/.ray/Language/String/Unicode.ray similarity index 100% rename from Ether/.ray/Language/String/Unicode.ray rename to @ether/.ray/Language/String/Unicode.ray diff --git a/Ether/.ray/Node.ray b/@ether/.ray/Node.ray similarity index 100% rename from Ether/.ray/Node.ray rename to @ether/.ray/Node.ray diff --git a/Ether/.ray/Program.ray b/@ether/.ray/Program.ray similarity index 100% rename from Ether/.ray/Program.ray rename to @ether/.ray/Program.ray diff --git a/Ether/.ray/Quest.ray b/@ether/.ray/Quest.ray similarity index 100% rename from Ether/.ray/Quest.ray rename to @ether/.ray/Quest.ray diff --git a/Ether/.ray/Ray.ray b/@ether/.ray/Ray.ray similarity index 100% rename from Ether/.ray/Ray.ray rename to @ether/.ray/Ray.ray diff --git a/Ether/.ray/Reference.ray b/@ether/.ray/Reference.ray similarity index 100% rename from Ether/.ray/Reference.ray rename to @ether/.ray/Reference.ray diff --git a/Ether/.ray/World.ray b/@ether/.ray/World.ray similarity index 100% rename from Ether/.ray/World.ray rename to @ether/.ray/World.ray diff --git a/Ether/.ts/index.ts b/@ether/.ts/index.ts similarity index 100% rename from Ether/.ts/index.ts rename to @ether/.ts/index.ts diff --git a/Ether/.ts/package-lock.json b/@ether/.ts/package-lock.json similarity index 100% rename from Ether/.ts/package-lock.json rename to @ether/.ts/package-lock.json diff --git a/Ether/.ts/package.json b/@ether/.ts/package.json similarity index 100% rename from Ether/.ts/package.json rename to @ether/.ts/package.json diff --git a/Ether/.ts/src/runtime/bench.ts b/@ether/.ts/src/runtime/bench.ts similarity index 100% rename from Ether/.ts/src/runtime/bench.ts rename to @ether/.ts/src/runtime/bench.ts diff --git a/Ether/.ts/src/runtime/parser.ts b/@ether/.ts/src/runtime/parser.ts similarity index 100% rename from Ether/.ts/src/runtime/parser.ts rename to @ether/.ts/src/runtime/parser.ts diff --git a/Ether/.ts/src/runtime/v0.run.ts b/@ether/.ts/src/runtime/v0.run.ts similarity index 100% rename from Ether/.ts/src/runtime/v0.run.ts rename to @ether/.ts/src/runtime/v0.run.ts diff --git a/Ether/.ts/src/runtime/v0.ts b/@ether/.ts/src/runtime/v0.ts similarity index 100% rename from Ether/.ts/src/runtime/v0.ts rename to @ether/.ts/src/runtime/v0.ts diff --git a/Ether/.ts/src/runtime/v0_old.ts b/@ether/.ts/src/runtime/v0_old.ts similarity index 100% rename from Ether/.ts/src/runtime/v0_old.ts rename to @ether/.ts/src/runtime/v0_old.ts diff --git a/Ether/.ts/tsconfig.json b/@ether/.ts/tsconfig.json similarity index 100% rename from Ether/.ts/tsconfig.json rename to @ether/.ts/tsconfig.json diff --git a/Ether/Ether.ray b/@ether/Ether.ray similarity index 91% rename from Ether/Ether.ray rename to @ether/Ether.ray index c3cb5641..7f722d1f 100644 --- a/Ether/Ether.ray +++ b/@ether/Ether.ray @@ -14,6 +14,8 @@ @guests => // (INSTANCE.hierarchy ->).reduce(|) @public => @ether.@everyone | Instance +@npc | @npcs => @public{: NPC} +@player | @players => @public{: Player} @localhost => "127.0.0.1" | "::1" @localnetwork => // https://datatracker.ietf.org/doc/html/rfc1918#section-3 diff --git a/Ether/README.md b/@ether/README.md similarity index 100% rename from Ether/README.md rename to @ether/README.md diff --git a/Ether/.html/public/images/E.png b/@ether/avatar/2d-square.png similarity index 100% rename from Ether/.html/public/images/E.png rename to @ether/avatar/2d-square.png diff --git a/Ether/.html/public/images/E.svg b/@ether/avatar/2d-square.svg similarity index 100% rename from Ether/.html/public/images/E.svg rename to @ether/avatar/2d-square.svg diff --git a/Ether/.html/public/images/Ether.png b/@ether/avatar/2d.png similarity index 100% rename from Ether/.html/public/images/Ether.png rename to @ether/avatar/2d.png diff --git a/Ether/.html/public/images/Ether.svg b/@ether/avatar/2d.svg similarity index 100% rename from Ether/.html/public/images/Ether.svg rename to @ether/avatar/2d.svg diff --git a/Ether/entrypoint.npc.ray b/@ether/entrypoint.npc.ray similarity index 100% rename from Ether/entrypoint.npc.ray rename to @ether/entrypoint.npc.ray diff --git a/Ether/entrypoint.player.ray b/@ether/entrypoint.player.ray similarity index 100% rename from Ether/entrypoint.player.ray rename to @ether/entrypoint.player.ray diff --git a/Ether/entrypoint.ray b/@ether/entrypoint.ray similarity index 100% rename from Ether/entrypoint.ray rename to @ether/entrypoint.ray diff --git a/Ether/entrypoint.server.ray b/@ether/entrypoint.server.ray similarity index 100% rename from Ether/entrypoint.server.ray rename to @ether/entrypoint.server.ray diff --git a/Ether/library/Index.ray b/@ether/library/Index.ray similarity index 100% rename from Ether/library/Index.ray rename to @ether/library/Index.ray diff --git a/Ether/projects/library/README.md b/@ether/projects/library/README.md similarity index 100% rename from Ether/projects/library/README.md rename to @ether/projects/library/README.md diff --git a/Ether/projects/library/phases/Project Index.md b/@ether/projects/library/phases/Project Index.md similarity index 100% rename from Ether/projects/library/phases/Project Index.md rename to @ether/projects/library/phases/Project Index.md diff --git a/Ether/.html/index.ray b/Ether/.html/index.ray deleted file mode 100644 index e69de29b..00000000 diff --git a/Ether/.html/index.ts b/Ether/.html/index.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/Ether/.html/src-tauri/src/lib.rs b/Ether/.html/src-tauri/src/lib.rs deleted file mode 100644 index 3b48e91a..00000000 --- a/Ether/.html/src-tauri/src/lib.rs +++ /dev/null @@ -1,6 +0,0 @@ -#[cfg_attr(mobile, tauri::mobile_entry_point)] -pub fn run() { - tauri::Builder::default() - .run(tauri::generate_context!()) - .expect("error while running tauri application"); -} diff --git a/Ether/.html/src-tauri/src/main.rs b/Ether/.html/src-tauri/src/main.rs deleted file mode 100644 index f8a15abf..00000000 --- a/Ether/.html/src-tauri/src/main.rs +++ /dev/null @@ -1,16 +0,0 @@ -// Prevents additional console window on Windows in release, DO NOT REMOVE!! -#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] - -fn main() { - match std::env::args().nth(1).as_deref() { - Some("version" | "%") => { - #[cfg(windows)] - { - extern "system" { fn AttachConsole(id: u32) -> i32; } - unsafe { AttachConsole(u32::MAX); } - } - println!("Ether {}", env!("CARGO_PKG_VERSION")); - } - _ => ether_lib::run(), - } -} diff --git a/Ether/.html/vite.config.mjs b/Ether/.html/vite.config.mjs deleted file mode 100644 index f95fa933..00000000 --- a/Ether/.html/vite.config.mjs +++ /dev/null @@ -1,8 +0,0 @@ -import {defineConfig} from 'vite'; - -export default defineConfig({ - base: './', - build: { - outDir: './dist' - }, -}); \ No newline at end of file diff --git a/Ether/E.png b/Ether/E.png deleted file mode 100644 index 2deaf479..00000000 Binary files a/Ether/E.png and /dev/null differ diff --git a/Ether/E.svg b/Ether/E.svg deleted file mode 100644 index 922ceece..00000000 --- a/Ether/E.svg +++ /dev/null @@ -1,68 +0,0 @@ - - - - - - - - - - - - - - diff --git a/Ether/Ether.png b/Ether/Ether.png deleted file mode 100644 index b0a83d5d..00000000 Binary files a/Ether/Ether.png and /dev/null differ diff --git a/Ether/Ether.svg b/Ether/Ether.svg deleted file mode 100644 index d2ab9c74..00000000 --- a/Ether/Ether.svg +++ /dev/null @@ -1,111 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/README.md b/README.md index 7c895efe..f19ebc3a 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ This thing is, in essence, a programming language (Ray) and an IDE (Ether), whic - If you prefer **video**, see [