diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..2a0cb5a --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,12 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "dev", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "port": 5173, + "autoPort": true + } + ] +} diff --git a/.prettierignore b/.prettierignore index 7d74fe2..2293a05 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,3 +7,4 @@ bun.lockb # Miscellaneous /static/ +.claude/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..94bc589 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,218 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What is BrowserCode? + +BrowserCode is a web-based IDE that runs entirely in the browser using WebAssembly. It has two experiences: a **playground IDE** at `/ide` (editor + file tree + terminals + live preview, with per-framework starter templates) and **AI coding CLIs** (Claude Code, Gemini CLI) at `/agents/[tool]`. Both are built on BrowserPod, which provides a sandboxed Node.js environment with persistent filesystem storage, POSIX CLI tools (bash, git, npm), and instant app previews via WebAssembly-based portal URLs. + +See `docs/codebase-guide.md` for a full structural tour. + +## Quick Start + +```bash +npm install # Install dependencies +npm run dev # Start development server at http://localhost:5173 +npm run check # Run type checking and Svelte validation +npm run lint # Check code style with ESLint and Prettier +npm run format # Auto-format code with Prettier +npm run build # Build for production +npm run preview # Preview production build locally +``` + +## Tech Stack + +- **Framework**: SvelteKit 2.50.2 (Svelte 5.51.0) +- **Build**: Vite 7.3.1 +- **Styling**: Tailwind CSS 4.1.18 +- **Runtime**: BrowserPod 2.8.0 (WebAssembly Node.js environment) +- **Type System**: TypeScript 5.9.3 (strict mode) +- **Icons**: Iconify + mingcute icon set +- **Code Quality**: ESLint 9.39.2, Prettier 3.8.1 + +## High-Level Architecture + +### User-Facing Flow + +``` +User visits browsercode.io + ↓ +/ redirects to /ide (playground); agents live at /agents/claude, /agents/gemini + ↓ ++layout.svelte renders: Sidebar + Main Content Area + ↓ +/ide: ide/+page.svelte → IdeSession.boot() hydrates a framework template into a pod +/agents/[tool]: agents/[tool]/+page.svelte → bootCLI() boots the tool's disk image + ↓ +Terminal renders stdio output; Portal iframes live previews +``` + +### Routing & Tool Selection + +The app is a client-rendered SPA (`adapter-static`, `ssr = false`, `200.html` fallback), so all redirects below run client-side. + +- **Root**: `/` redirects to `/ide` (`src/routes/+page.ts`) +- **Playground IDE**: `/ide`, framework selected via `?framework=` (registry in `lib/config/frameworks.ts`; templates in `static/templates/`) +- **Agent Pages**: `/agents/[tool]` (`routes/agents/[tool]/+page.svelte`) +- **Legacy URLs**: `/claude`, `/gemini`, … redirect to `/agents/` (`routes/[tool]/+page.ts`) +- **Supported Tools** (lib/config/tools.ts): + - Claude Code: enabled, uses claude_20260506 disk image + - Gemini CLI: enabled, uses gemini_20260430_2 disk image + - Codex, OpenCode: disabled (coming soon) + +Tool/IDE switching uses full page loads (`window.location.href`) on purpose — that is the teardown mechanism for the running pod. + +### Component Hierarchy + +**+layout.svelte** (root layout) + +- Renders Sidebar (left) + main content (right) +- Manages tool validation and active tool state +- Sets up OG meta tags for social sharing + +**agents/[tool]/+page.svelte** (agent application) + +- Terminal component (simple div#console container) +- Portal component (preview iframe + controls) +- Desktop: split layout with drag-to-resize (portalFraction state) +- Mobile: tab navigation between Terminal and Preview views +- Handles portal updates from BrowserPod callbacks + +**ide/+page.svelte** (playground application) + +- Dispatches to IdeLanding (bare `/ide`) or IdeShell (framework/GitHub boot); IdeShell composes the icon rail, resizable FileTreePanel side panel, a header project switcher (template + GitHub clone), EditorPane (Monaco), TerminalTabs (boot/dev-server terminal + closeable extra bash terminals via "+"), Portal preview +- Pod lifecycle and file/portal state live in `lib/ide/session.svelte.ts` (IdeSession); pod file I/O helpers in `lib/ide/pod-fs.ts` +- `lib/components/ide/EditorPane.svelte` + `lib/components/ide/monaco.ts` are the only files importing `monaco-editor` — they are designed to be replaced by VS Code Web later; session/pod-fs/frameworks survive that migration + +**Portal.svelte** + +- iFrame displaying preview URLs +- Port selector (if multiple services running) +- Menu for copy URL, QR code, open in new tab +- QR code generation using qrcode library + +**Sidebar.svelte** + +- Fixed-width nav (desktop only, hidden mobile) +- Tool buttons, GitHub/Discord links, Help button +- Uses Iconify icons with hover tooltips + +**Stepper.svelte** + +- Onboarding tutorial modal +- State managed via stepperState store + +### Data Flow: BrowserPod & Portals + +``` +lib/utils/main.ts: bootCLI() + 1. Detects iOS (unsupported platform) + 2. Initializes BrowserPod with VITE_API_KEY env var + 3. Creates terminal linked to #console div + 4. Sets up portal listener (pod.onPortal callback) + 5. Creates /home/user/project directory + 6. Copies optional project file (CLAUDE.md or GEMINI.md) + 7. Runs CLI: node /path/to/cli.js in /home/user/project + +BrowserPod callbacks: + - Portal events: { port, url } → portalUpdate state → Portal component + - Open events: Optional custom handler (e.g., OAuth URL rewriting for Claude) +``` + +**Portal Updates**: When a service starts on a port, BrowserPod emits `{ port, url, active: true }`. This is collected in the `portals` array (state in [tool]/+page.svelte), and the selected portal renders in the iFrame. + +### Storage & Persistence + +- Each tool has a `storageKey` (e.g., `claude_20260506`) that maps to a BrowserPod disk image URL +- BrowserPod caches the WebAssembly filesystem locally (IndexedDB) +- Changes persist across sessions within the same storage key +- Disk images are versioned; updating the version syncs a new baseline + +### Configuration + +**lib/config/tools.ts** defines: + +- Tool metadata (id, label, icon, disabled flag) +- CLI configurations: disk image URL, storage key, command, args, optional project file, optional open callback + +To add a new tool: + +1. Add to `toolItems` array +2. Add config to `cliConfigs` object +3. Ensure disk image is available at the WSS URL + +## Development Workflow + +### Adding a Component + +1. Create a `.svelte` file in `src/lib/components/` +2. Use ` + + + +{#if leaveWarningState.open} + +{/if} diff --git a/src/lib/components/Portal.svelte b/src/lib/components/Portal.svelte index 4ddcf1b..e7289c0 100644 --- a/src/lib/components/Portal.svelte +++ b/src/lib/components/Portal.svelte @@ -64,7 +64,7 @@
@@ -76,7 +76,7 @@ {#if portals.length > 1}
{ + if (e.key === 'Enter') onCommit(); + else if (e.key === 'Escape') cancelAction(); + }} + onblur={cancelAction} + oninput={() => (actionError = '')} + class="min-w-0 flex-1 rounded border bg-zinc-900 px-1 py-0.5 text-[11px] text-zinc-100 outline-none disabled:opacity-60 {actionError + ? 'border-rose-500/60' + : 'border-emerald-500/50'}" + /> +
+ {#if actionError} +

{actionError}

+ {/if} +
+{/snippet} + +{#snippet menuItem(icon: string, label: string, onSelect: () => void, danger = false)} + +{/snippet} + +{#snippet treeNode(nodes: TreeNode[], depth: number, parentPath: string)} + {#if pendingCreate && pendingCreate.parent === parentPath} + {@render nameInput( + 0.75 + depth * 0.75, + pendingCreate.kind === 'folder' ? 'mingcute:folder-line' : 'mingcute:file-line', + commitCreate + )} + {/if} + {#each nodes as node (node.path)} + {#if node.children} + {#if renaming?.path === node.path} + {@render nameInput(0.5 + depth * 0.75, 'mingcute:folder-line', commitRename)} + {:else} + + {/if} + {#if expandedFolders.has(node.path)} + {@render treeNode(node.children, depth + 1, node.path)} + {/if} + {:else if renaming?.path === node.path} + {@render nameInput(0.75 + depth * 0.75, 'mingcute:file-line', commitRename)} + {:else} + + {/if} + {/each} +{/snippet} + +{@render treeNode(fileTree, 0, '')} + +{#if menu} + {@const target = menu} + + +{/if} + +{#if confirmDelete} + {@const entryName = confirmDelete.path.split('/').pop()} +
+
+

+ Delete {confirmDelete.isDir ? 'folder' : 'file'} “{entryName}”? +

+

+ {#if deleteChildCount > 0} + It contains {deleteChildCount} file{deleteChildCount === 1 ? '' : 's'}. This cannot be + undone. + {:else} + This cannot be undone. + {/if} +

+ {#if deleteError} +

{deleteError}

+ {/if} +
+ + +
+
+
+{/if} diff --git a/src/lib/components/ide/IdeLanding.svelte b/src/lib/components/ide/IdeLanding.svelte new file mode 100644 index 0000000..5081734 --- /dev/null +++ b/src/lib/components/ide/IdeLanding.svelte @@ -0,0 +1,52 @@ + + +
+
+

IDE Playground

+

Start from a framework template.

+ + + + + Runs entirely in a BrowserPod sandbox — a WebAssembly Node.js + environment with a real filesystem, npm and git. Nothing installs on your machine. + + + +
+
+ Frameworks +
+
+ {#each frameworkRailItems as fw (fw.id)} + + {/each} +
+
+
+
diff --git a/src/lib/components/ide/IdeShell.svelte b/src/lib/components/ide/IdeShell.svelte new file mode 100644 index 0000000..b166963 --- /dev/null +++ b/src/lib/components/ide/IdeShell.svelte @@ -0,0 +1,607 @@ + + +
+ +
+
+ + {session.displayLabel} + {#if session.selectedFile} + / + {session.selectedFile} + {/if} + {#if session.isSaving} + saving… + {/if} +
+
+ + +
+ + + + + {#if isMobile && activePanel} + + {/if} + + + {#if activePanel} +
+
+ + Files + +
+ + +
+
+
+ isMobile && (activePanel = null)} + /> +
+
+ + + + {/if} + + +
+ +
+
+ +
+ + + {#if !isMobile} + + {/if} + +
+ +
+
+ + + {#if !isMobile} + + {/if} + + +
+ {#if !isCompatibleBrowser} +
+
+
+ +
+

Incompatible Browser

+

+ Requires Atomics.waitAsync (Chrome, Edge, Safari + 16.4+). +

+
+
+ {:else} + {#if portals.length > 0} + + {/if} + + {#if loaderVisible} +
+ +
+ {/if} + {/if} +
+
+
+ + + {#if isMobile} + + {/if} +
+ + diff --git a/src/lib/components/ide/PreviewLoader.svelte b/src/lib/components/ide/PreviewLoader.svelte new file mode 100644 index 0000000..405cf58 --- /dev/null +++ b/src/lib/components/ide/PreviewLoader.svelte @@ -0,0 +1,500 @@ + + +
+ {#if webglFailed} +
+ {:else} + + {/if} + + + +
+
+ + BrowserCode +
+
+ Booting {label} +
+
+
+ + diff --git a/src/lib/components/ide/TerminalTabs.svelte b/src/lib/components/ide/TerminalTabs.svelte new file mode 100644 index 0000000..960c90a --- /dev/null +++ b/src/lib/components/ide/TerminalTabs.svelte @@ -0,0 +1,118 @@ + + +
+
+ + {#each bashTabs as tab (tab.id)} +
+ + +
+ {/each} + +
+
+
+ {#each bashTabs as tab (tab.id)} +
+ {/each} +
+
+ + diff --git a/src/lib/components/ide/monaco.ts b/src/lib/components/ide/monaco.ts new file mode 100644 index 0000000..b0cb5dd --- /dev/null +++ b/src/lib/components/ide/monaco.ts @@ -0,0 +1,86 @@ +import * as monaco from 'monaco-editor'; +import { typescript as monacoTs } from 'monaco-editor'; +import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'; +import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker'; +import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker'; +import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker'; +import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker'; + +/** + * Monaco bootstrap for the playground editor. This module (and EditorPane, which + * dynamically imports it) is the only place that touches `monaco-editor` + */ + +self.MonacoEnvironment = { + getWorker(_workerId: string, label: string): Worker { + if (label === 'json') return new jsonWorker(); + if (label === 'css' || label === 'scss' || label === 'less') return new cssWorker(); + if (label === 'html' || label === 'handlebars' || label === 'razor') return new htmlWorker(); + if (label === 'typescript' || label === 'javascript') return new tsWorker(); + return new editorWorker(); + } +}; + +// Browserpod have no node_modules the TS worker can resolve, so semantic +// validation would flag every import as "cannot find module". Keep syntax checks. +for (const defaults of [monacoTs.typescriptDefaults, monacoTs.javascriptDefaults]) + defaults.setDiagnosticsOptions({ noSemanticValidation: true, noSyntaxValidation: false }); + +monaco.editor.defineTheme('browsercode-dark', { + base: 'vs-dark', + inherit: true, + rules: [], + colors: { + 'editor.background': '#09090b', + 'editor.foreground': '#d9d9d9', + 'editorCursor.foreground': '#10b981', + 'editor.selectionBackground': '#10b9812e', + 'editor.inactiveSelectionBackground': '#10b9811a', + 'editor.lineHighlightBackground': '#ffffff05', + 'editorLineNumber.foreground': '#ffffff40', + 'editorLineNumber.activeForeground': '#ffffff8c', + 'editorGutter.background': '#09090b', + 'editorIndentGuide.background1': '#ffffff0a', + 'editorIndentGuide.activeBackground1': '#ffffff1f', + 'editorWidget.background': '#111111', + 'editorWidget.border': '#ffffff14', + 'scrollbarSlider.background': '#ffffff1f', + 'scrollbarSlider.hoverBackground': '#ffffff38', + 'scrollbarSlider.activeBackground': '#ffffff38', + 'editorOverviewRuler.border': '#00000000' + } +}); + +// Svelte and Vue have no Monaco grammar, so they fall back to HTML. +const LANGUAGE_BY_EXT: Record = { + svelte: 'html', + vue: 'html', + html: 'html', + htm: 'html', + ts: 'typescript', + tsx: 'typescript', + mts: 'typescript', + cts: 'typescript', + js: 'javascript', + jsx: 'javascript', + mjs: 'javascript', + cjs: 'javascript', + css: 'css', + scss: 'scss', + less: 'less', + json: 'json', + md: 'markdown', + markdown: 'markdown', + yaml: 'yaml', + yml: 'yaml', + xml: 'xml', + svg: 'xml' +}; + +// Maps a file path to a Monaco language id by its extension; unknown types are plaintext. +export function languageFor(file: string): string { + const ext = file.slice(file.lastIndexOf('.') + 1).toLowerCase(); + return LANGUAGE_BY_EXT[ext] ?? 'plaintext'; +} + +export { monaco }; diff --git a/src/lib/config/frameworks.ts b/src/lib/config/frameworks.ts new file mode 100644 index 0000000..54a6aa2 --- /dev/null +++ b/src/lib/config/frameworks.ts @@ -0,0 +1,99 @@ +export type FrameworkId = 'vite' | 'svelte' | 'react' | 'vue' | 'nextjs' | 'nuxt' | 'express'; + +export type FrameworkConfig = { + id: FrameworkId; + label: string; + /** Static-asset path the template is served from (manifest.txt + project files). */ + sourceRoot: string; + /** File opened in the editor after boot. */ + defaultFile: string; + icon: string; + /** npm invocations run before the dev server, e.g. [['install']]. */ + setupCommandArgs?: string[][]; + /** npm invocation that starts the dev server. Defaults to ['run', 'dev']. */ + devCommandArgs?: string[]; + /** When set, only a portal on this port is auto-selected for the preview. */ + appPort?: number; +}; + +export const frameworkConfigs: Record = { + vite: { + id: 'vite', + label: 'Vite', + sourceRoot: '/templates/vite', + defaultFile: 'src/main.js', + icon: 'simple-icons:vite', + setupCommandArgs: [['install']], + devCommandArgs: ['run', 'dev'] + }, + svelte: { + id: 'svelte', + label: 'Vite + Svelte', + sourceRoot: '/templates/vite-svelte', + defaultFile: 'src/App.svelte', + icon: 'simple-icons:svelte', + setupCommandArgs: [['install']], + devCommandArgs: ['run', 'dev'] + }, + react: { + id: 'react', + label: 'Vite + React', + sourceRoot: '/templates/vite-react', + defaultFile: 'src/App.jsx', + icon: 'simple-icons:react', + setupCommandArgs: [['install']], + devCommandArgs: ['run', 'dev'] + }, + vue: { + id: 'vue', + label: 'Vite + Vue', + sourceRoot: '/templates/vite-vue', + defaultFile: 'src/App.vue', + icon: 'simple-icons:vuedotjs', + setupCommandArgs: [['install']], + devCommandArgs: ['run', 'dev'] + }, + nextjs: { + id: 'nextjs', + label: 'Next.js', + sourceRoot: '/templates/nextjs', + defaultFile: 'app/page.jsx', + icon: 'simple-icons:nextdotjs', + setupCommandArgs: [['install']], + devCommandArgs: ['run', 'dev'] + }, + nuxt: { + id: 'nuxt', + label: 'Nuxt', + sourceRoot: '/templates/nuxt', + defaultFile: 'app/app.vue', + icon: 'simple-icons:nuxtdotjs', + setupCommandArgs: [['pkg', 'get', 'dependencies.nuxt'], ['install']], + devCommandArgs: ['run', 'dev'], + appPort: 3000 + }, + // Astro is blocked upstream (image too large for BrowserPod); Angular tries to + // serve via `ng serve`, which is also unsupported. Re-add here once unblocked. + express: { + id: 'express', + label: 'Express', + sourceRoot: '/templates/express', + defaultFile: 'server.js', + icon: 'simple-icons:nodedotjs', + setupCommandArgs: [['install']], + devCommandArgs: ['run', 'dev'], + appPort: 4000 + } +}; + +export const defaultFrameworkId: FrameworkId = 'express'; + +export function isFrameworkId(value: string | null): value is FrameworkId { + return value !== null && value in frameworkConfigs; +} + +export const frameworkRailItems = (Object.keys(frameworkConfigs) as FrameworkId[]).map((id) => ({ + id, + icon: frameworkConfigs[id].icon, + label: frameworkConfigs[id].label +})); diff --git a/src/lib/config/tools.ts b/src/lib/config/tools.ts index f965647..8076b12 100644 --- a/src/lib/config/tools.ts +++ b/src/lib/config/tools.ts @@ -5,13 +5,46 @@ export type ToolItem = { icon: string | null; label: string; disabled: boolean; + /** Tailwind classes for the icon badge when the tool is available (ignored while disabled). */ + accentClass: string; + /** Solid Tailwind background class for the small "this one is running" status dot. */ + dotClass: string; }; export const toolItems: ToolItem[] = [ - { id: 'claude', icon: 'mingcute:claude-line', label: 'Claude Code', disabled: false }, - { id: 'gemini', icon: 'simple-icons:googlegemini', label: 'Gemini CLI', disabled: false }, - { id: 'codex', icon: 'hugeicons:chat-gpt', label: 'Codex CLI', disabled: true }, - { id: 'opencode', icon: null, label: 'OpenCode', disabled: true } + { + id: 'claude', + icon: 'mingcute:claude-line', + label: 'Claude Code', + disabled: false, + // Original brand colors, not the app's accent palette — kept recognizable at a glance. + accentClass: 'bg-orange-500/10 text-orange-400', + dotClass: 'bg-orange-400' + }, + { + id: 'gemini', + icon: 'simple-icons:googlegemini', + label: 'Gemini CLI', + disabled: false, + accentClass: 'bg-blue-500/10 text-blue-400', + dotClass: 'bg-blue-400' + }, + { + id: 'codex', + icon: 'hugeicons:chat-gpt', + label: 'Codex CLI', + disabled: true, + accentClass: 'bg-bc-orchid/10 text-bc-orchid', + dotClass: 'bg-bc-orchid' + }, + { + id: 'opencode', + icon: null, + label: 'OpenCode', + disabled: true, + accentClass: 'bg-bc-coral/10 text-bc-coral', + dotClass: 'bg-bc-coral' + } ]; export type CLIConfig = { @@ -29,7 +62,7 @@ export const cliConfigs: Record = { storageKey: 'claude_20260506', command: 'node', args: ['/home/user/claude-extracted/src/entrypoints/cli.js'], - projectFile: 'project/claude/CLAUDE.md', + projectFile: '/project/claude/CLAUDE.md', openCallback: (urlOrPath: string) => { if ( urlOrPath.startsWith('https://claude.com/cai/oauth/authorize') || @@ -49,6 +82,6 @@ export const cliConfigs: Record = { storageKey: 'gemini_20260430_2', command: 'node', args: ['/home/user/node_modules/@google/gemini-cli/bundle/gemini.js'], - projectFile: 'project/gemini/GEMINI.md' + projectFile: '/project/gemini/GEMINI.md' } }; diff --git a/src/lib/github/api.ts b/src/lib/github/api.ts new file mode 100644 index 0000000..929a2cf --- /dev/null +++ b/src/lib/github/api.ts @@ -0,0 +1,118 @@ +/** + * Minimal GitHub REST helper for the IDE's GitHub-clone boot mode. Browser `fetch`, + * unauthenticated (60 req/hr). `fetchRepoTree` returns the repo's file list so the tree + * can render before/while the pod clones. + * + * For a sub-directory boot we resolve that directory's tree SHA first and fetch + * recursively from there: the request stays small (matters for big monorepos), dodges the + * recursive-tree truncation cap, and yields paths already relative to `dir`. + */ + +const API_ROOT = 'https://api.github.com'; + +// Bounds for the truncated-tree fallback walk (only reached for huge repo roots). +const MAX_TREE_REQUESTS = 200; +const MAX_FILES = 20_000; + +type GitTreeEntry = { path: string; type: 'blob' | 'tree' | 'commit'; sha: string }; +type GitTreeResponse = { sha: string; tree: GitTreeEntry[]; truncated: boolean }; + +function enc(segment: string): string { + return encodeURIComponent(segment); +} + +/** Strip leading/trailing slashes and `.`/empty segments so `dir` is a clean a/b/c. */ +function normalizeDir(dir: string): string { + return dir + .split('/') + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0 && segment !== '.') + .join('/'); +} + +async function githubFetch(path: string): Promise { + let response: Response; + try { + response = await fetch(`${API_ROOT}${path}`, { + headers: { Accept: 'application/vnd.github+json' } + }); + } catch (cause) { + throw new Error('Network request to GitHub failed', { cause }); + } + + if (response.ok) return response.json(); + + if (response.status === 404) throw new Error('Repository, ref or path not found'); + if (response.status === 403 && response.headers.get('x-ratelimit-remaining') === '0') + throw new Error('GitHub API rate limit exceeded (unauthenticated requests are limited to 60/hour)'); + throw new Error(`GitHub API request failed (${response.status})`); +} + +/** Walk `dir` one segment at a time from `ref`, returning the tree SHA of the sub-directory. */ +async function resolveSubtreeSha( + owner: string, + repo: string, + ref: string, + dir: string +): Promise { + let sha = ref; + for (const segment of dir.split('/')) { + const tree = (await githubFetch( + `/repos/${enc(owner)}/${enc(repo)}/git/trees/${enc(sha)}` + )) as GitTreeResponse; + const match = tree.tree.find((entry) => entry.type === 'tree' && entry.path === segment); + if (!match) throw new Error(`Directory "${dir}" not found in ${owner}/${repo}@${ref}`); + sha = match.sha; + } + return sha; +} + +/** Capped breadth-first walk used when the recursive tree comes back truncated. */ +async function walkTree(owner: string, repo: string, rootSha: string): Promise { + const files: string[] = []; + const queue: Array<{ sha: string; prefix: string }> = [{ sha: rootSha, prefix: '' }]; + let requests = 0; + + while (queue.length > 0) { + if (requests >= MAX_TREE_REQUESTS || files.length >= MAX_FILES) break; + const { sha, prefix } = queue.shift()!; + requests++; + const tree = (await githubFetch( + `/repos/${enc(owner)}/${enc(repo)}/git/trees/${enc(sha)}` + )) as GitTreeResponse; + for (const entry of tree.tree) { + const path = prefix ? `${prefix}/${entry.path}` : entry.path; + if (entry.type === 'blob') files.push(path); + else if (entry.type === 'tree') queue.push({ sha: entry.sha, prefix: path }); + } + } + + files.sort(); + return files; +} + +/** Returns the repo's blob paths relative to `dir` (repo root when `dir` is empty), sorted. */ +export async function fetchRepoTree( + owner: string, + repo: string, + ref: string, + dir = '' +): Promise { + const normalizedDir = normalizeDir(dir); + + // Scope to the sub-directory so paths come back relative to `dir` and the request stays + // small; the repo root is fetched straight from `ref`. + const startSha = normalizedDir ? await resolveSubtreeSha(owner, repo, ref, normalizedDir) : ref; + + const recursive = (await githubFetch( + `/repos/${enc(owner)}/${enc(repo)}/git/trees/${enc(startSha)}?recursive=1` + )) as GitTreeResponse; + + if (!recursive.truncated) + return recursive.tree + .filter((entry) => entry.type === 'blob') + .map((entry) => entry.path) + .sort(); + + return walkTree(owner, repo, startSha); +} diff --git a/src/lib/ide/pod-fs.ts b/src/lib/ide/pod-fs.ts new file mode 100644 index 0000000..94af1e2 --- /dev/null +++ b/src/lib/ide/pod-fs.ts @@ -0,0 +1,35 @@ +import type { BinaryFile, BrowserPod, TextFile } from '@leaningtech/browserpod'; + +/** Default working directory. Curated templates hydrate here; GitHub clones live in a subdir. */ +export const POD_HOME = '/home/user'; + +export async function readPodFile(pod: BrowserPod, absPath: string): Promise { + const file = (await pod.openFile(absPath, 'utf-8')) as TextFile; + const size = await file.getSize(); + const content = await file.read(size); + await file.close(); + return content; +} + +export async function writePodFile(pod: BrowserPod, absPath: string, content: string): Promise { + await ensureParentDirectory(pod, absPath); + const file = (await pod.createFile(absPath, 'utf-8')) as TextFile; + await file.write(content); + await file.close(); +} + +export async function writePodBinaryFile( + pod: BrowserPod, + absPath: string, + content: ArrayBuffer +): Promise { + await ensureParentDirectory(pod, absPath); + const file = (await pod.createFile(absPath, 'binary')) as BinaryFile; + await file.write(content); + await file.close(); +} + +async function ensureParentDirectory(pod: BrowserPod, absPath: string): Promise { + const parent = absPath.slice(0, absPath.lastIndexOf('/')); + if (parent) await pod.createDirectory(parent, { recursive: true }); +} diff --git a/src/lib/ide/session.svelte.ts b/src/lib/ide/session.svelte.ts new file mode 100644 index 0000000..79d5ac5 --- /dev/null +++ b/src/lib/ide/session.svelte.ts @@ -0,0 +1,523 @@ +import type { BrowserPod, Terminal } from '@leaningtech/browserpod'; +import { SvelteSet } from 'svelte/reactivity'; +import { + frameworkConfigs, + defaultFrameworkId, + type FrameworkConfig, + type FrameworkId +} from '$lib/config/frameworks'; +import { POD_HOME, readPodFile, writePodFile, writePodBinaryFile } from './pod-fs'; +import { fetchRepoTree } from '$lib/github/api'; +import { trackEvent } from '$lib/utils/useLazyTracking'; + +export type PortalUpdate = { port: number; url: string | null; active: boolean }; + +/** + * A file open as an editor tab. A `preview` tab (opened by single-click) + * is reused by the next preview open; double-clicking or editing pins it. + */ +export type OpenFile = { path: string; content: string; savedContent: string; preview: boolean }; + +/** + * Owns the BrowserPod lifecycle for the playground IDE: boots the pod, hydrates + * the selected framework template into its filesystem, starts the dev server and + * exposes file/terminal/portal state. UI components render from this; the editor + * implementation (Monaco today, VS Code Web later) stays swappable on top. + */ +export class IdeSession { + framework = $state(defaultFrameworkId); + projectFiles = $state([]); + /** Explicitly-known directories (from UI folder actions); lets empty folders render in the tree. */ + projectDirs = $state([]); + /** Files open as editor tabs, in tab-strip order. */ + openFiles = $state([]); + /** Path of the active tab; '' when no tab is open. */ + selectedFile = $state(''); + loading = $state(true); + isSaving = $state(false); + hasPortal = $state(false); + podReady = $state(false); + + /** Which boot path produced this session; drives the label, appPort and workdir. */ + mode = $state<'framework' | 'github'>('framework'); + /** Directory the project lives in inside the pod; pod-fs paths resolve against it. */ + workdir = POD_HOME; + private githubSlug = $state(''); + + pod: BrowserPod | null = null; + outputTerminal: Terminal | null = null; + /** Lazily created hidden terminal that carries UI-initiated fs commands. */ + private fsTerminal: Terminal | null = null; + /** Serializes fs commands so two never share the hidden terminal at once. */ + private fsQueue: Promise = Promise.resolve(); + /** Paths with a save in flight; a second save of the same file waits for the next edit. */ + private savingPaths = new SvelteSet(); + /** Tabs pinned (double-clicked) while their first read was still in flight. */ + private pendingPins = new SvelteSet(); + + private unmounted = false; + private bootToken = 0; + private booting = false; + + get config(): FrameworkConfig { + return frameworkConfigs[this.framework]; + } + + /** Label shown in the IDE shell; framework label, or owner/repo[/dir] in GitHub mode. */ + get displayLabel(): string { + return this.mode === 'github' ? this.githubSlug : this.config.label; + } + + /** Preview is pinned to this port when set. Unknown for arbitrary GitHub repos. */ + get appPort(): number | undefined { + return this.mode === 'github' ? undefined : this.config.appPort; + } + + private get activeFile(): OpenFile | undefined { + return this.openFiles.find((file) => file.path === this.selectedFile); + } + + /** True when the active tab has unsaved edits. */ + get dirty(): boolean { + const file = this.activeFile; + return !!file && file.content !== file.savedContent; + } + + private cancelled(token: number): boolean { + return this.unmounted || token !== this.bootToken; + } + + async boot( + framework: FrameworkId, + terminalEl: HTMLElement, + onPortalUpdate: (update: PortalUpdate) => void + ): Promise { + if (this.booting || this.pod) return; + this.booting = true; + const token = ++this.bootToken; + try { + this.framework = framework; + this.projectFiles = await fetchManifest(this.config); + if (this.cancelled(token)) return; + + const { BrowserPod } = await import('@leaningtech/browserpod'); + if (this.cancelled(token)) return; + + const pod = await BrowserPod.boot({ + apiKey: import.meta.env.VITE_API_KEY as string + }); + if (this.cancelled(token)) { + void shutdownPod(pod); + return; + } + this.pod = pod; + + this.outputTerminal = await pod.createDefaultTerminal(terminalEl); + + for (const file of this.projectFiles) { + if (this.cancelled(token)) return; + const content = await fetchTemplateFile(this.config, file); + if (this.cancelled(token)) return; + await writePodBinaryFile(pod, `${this.workdir}/${file}`, content); + } + + this.podReady = true; + const initialFile = this.projectFiles.includes(this.config.defaultFile) + ? this.config.defaultFile + : this.projectFiles[0]; + if (initialFile) await this.openFile(initialFile); + + pod.onPortal(({ url, port }) => { + if (this.cancelled(token)) return; + const portNumber = Number(port); + if (!Number.isInteger(portNumber) || portNumber <= 0) return; + const trimmedUrl = typeof url === 'string' ? url.trim() : ''; + const active = trimmedUrl.length > 0; + if (active) this.hasPortal = true; + onPortalUpdate({ port: portNumber, url: active ? trimmedUrl : null, active }); + }); + + trackEvent('Booted Playground', { framework: this.config.label }); + + for (const setupCommandArgs of this.config.setupCommandArgs ?? [['install']]) { + if (this.cancelled(token)) return; + await pod.run('npm', setupCommandArgs, { + echo: true, + terminal: this.outputTerminal, + cwd: this.workdir + }); + } + + if (this.cancelled(token)) return; + await pod.run('npm', this.config.devCommandArgs ?? ['run', 'dev'], { + echo: true, + terminal: this.outputTerminal, + cwd: this.workdir + }); + } finally { + this.booting = false; + } + } + + /** + * Boots a pod from a GitHub repo: shallow clone, `npm install`, then the + * project's dev/start script. + * The file tree is fetched up front so it renders while the clone runs. + */ + async bootFromGitHub( + owner: string, + repo: string, + ref: string, + dir: string, + terminalEl: HTMLElement, + onPortalUpdate: (update: PortalUpdate) => void + ): Promise { + if (this.booting || this.pod) return; + this.booting = true; + this.mode = 'github'; + this.githubSlug = dir ? `${owner}/${repo}/${dir}` : `${owner}/${repo}`; + const token = ++this.bootToken; + try { + // File list up front (before the pod) so the tree renders while we clone. + try { + const files = await fetchRepoTree(owner, repo, ref, dir); + if (this.cancelled(token)) return; + this.projectFiles = files; + } catch (error) { + console.error('Failed to fetch repo file tree:', error); + } + + const { BrowserPod } = await import('@leaningtech/browserpod'); + if (this.cancelled(token)) return; + + const pod = await BrowserPod.boot({ + apiKey: import.meta.env.VITE_API_KEY as string + }); + if (this.cancelled(token)) { + void shutdownPod(pod); + return; + } + this.pod = pod; + + this.outputTerminal = await pod.createDefaultTerminal(terminalEl); + + pod.onPortal(({ url, port }) => { + if (this.cancelled(token)) return; + const portNumber = Number(port); + if (!Number.isInteger(portNumber) || portNumber <= 0) return; + const trimmedUrl = typeof url === 'string' ? url.trim() : ''; + const active = trimmedUrl.length > 0; + if (active) this.hasPortal = true; + onPortalUpdate({ port: portNumber, url: active ? trimmedUrl : null, active }); + }); + + const repoDir = `${POD_HOME}/${repo}`; + this.workdir = dir ? `${repoDir}/${dir}` : repoDir; + + // Plain shallow clone: fetch only the tip commit (--depth 1) of the requested branch + // and check out the full working tree. For a subdir boot we just point `workdir` at + // the subtree afterwards. (BrowserPod 2.12+ handles the packfile memory footprint, so + // the earlier --filter=blob:none / --sparse workaround is no longer needed.) + await pod.run( + 'git', + ['clone', '--depth', '1', '--branch', ref, `https://github.com/${owner}/${repo}.git`], + { + echo: true, + terminal: this.outputTerminal, + cwd: POD_HOME + } + ); + if (this.cancelled(token)) return; + + // The working tree exists now — let the editor read from the pod. + this.podReady = true; + const initialFile = this.projectFiles[0]; + if (initialFile) await this.openFile(initialFile); + else this.loading = false; + + trackEvent('Booted Playground GitHub', { repo: `${owner}/${repo}` }); + + await pod.run('npm', ['install'], { + echo: true, + terminal: this.outputTerminal, + cwd: this.workdir + }); + if (this.cancelled(token)) return; + + const script = await this.resolveStartScript(); + if (this.cancelled(token)) return; + if (!script) { + if (this.outputTerminal) + (this.outputTerminal as Terminal & { write?: (data: string) => void }).write?.( + '\r\nNo "dev" or "start" script in package.json — nothing to run.\r\n' + ); + return; + } + + await pod.run('npm', ['run', script], { + echo: true, + terminal: this.outputTerminal, + cwd: this.workdir + }); + } finally { + this.booting = false; + } + } + + /** Pick the dev-server script from the cloned package.json: prefer `dev`, then `start`. */ + private async resolveStartScript(): Promise { + if (!this.pod) return null; + try { + const raw = await readPodFile(this.pod, `${this.workdir}/package.json`); + const scripts = (JSON.parse(raw) as { scripts?: Record }).scripts ?? {}; + if (scripts.dev) return 'dev'; + if (scripts.start) return 'start'; + return null; + } catch (error) { + console.error('Failed to read package.json:', error); + return null; + } + } + + /** True if the tree already knows an entry at `path` (or something nested under it). */ + private entryExists(path: string): boolean { + const under = (p: string) => p === path || p.startsWith(`${path}/`); + return this.projectFiles.some(under) || this.projectDirs.some(under); + } + + /** + * Runs a shell command in a hidden terminal (BrowserPod has no rename/delete + * API). Fire-and-trust: `run` resolves on process exit but exposes no exit + * code, and probing the result through the file API is unreliable (its view + * can lag the process world), so callers update the tree optimistically once + * the command finishes. Serialized on a queue so two commands never share the + * hidden terminal at once. Returns an error message or null. + */ + private runFsCommand(command: string): Promise { + const task = async (): Promise => { + if (!this.pod || !this.podReady) return 'Pod is not ready yet'; + try { + if (!this.fsTerminal) { + const decoder = new TextDecoder(); + this.fsTerminal = await this.pod.createCustomTerminal({ + onOutput: (chunk) => console.debug('[ide-fs]', decoder.decode(chunk, { stream: true })) + }); + } + await this.pod.run('bash', ['-c', command], { + echo: true, + terminal: this.fsTerminal, + cwd: this.workdir + }); + return null; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + }; + const result = this.fsQueue.then(task, task); + this.fsQueue = result.catch(() => undefined); + return result; + } + + /** Creates an empty file and opens it in the editor. Returns an error message or null. */ + async createFile(path: string): Promise { + if (!this.pod || !this.podReady) return 'Pod is not ready yet'; + if (this.entryExists(path)) return 'Something with that name already exists'; + try { + await writePodFile(this.pod, `${this.workdir}/${path}`, ''); + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + this.projectFiles = [...this.projectFiles, path]; + await this.openFile(path); + return null; + } + + /** Creates a folder. Returns an error message or null. */ + async createFolder(path: string): Promise { + if (!this.pod || !this.podReady) return 'Pod is not ready yet'; + if (this.entryExists(path)) return 'Something with that name already exists'; + try { + await this.pod.createDirectory(`${this.workdir}/${path}`, { recursive: true }); + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + this.projectDirs = [...this.projectDirs, path]; + return null; + } + + /** Renames a file or folder; open tabs and the selection follow the moved path. Returns an error message or null. */ + async renameEntry(from: string, to: string): Promise { + if (this.entryExists(to)) return 'Something with that name already exists'; + const failure = await this.runFsCommand(`mv -- ${shQuote(from)} ${shQuote(to)}`); + if (failure) return failure; + const remap = (p: string) => + p === from ? to : p.startsWith(`${from}/`) ? to + p.slice(from.length) : p; + this.projectFiles = this.projectFiles.map(remap); + this.projectDirs = this.projectDirs.map(remap); + for (const file of this.openFiles) file.path = remap(file.path); + this.selectedFile = remap(this.selectedFile); + return null; + } + + /** Deletes a file or folder recursively; tabs under the path close. Returns an error message or null. */ + async deleteEntry(path: string): Promise { + const failure = await this.runFsCommand(`rm -rf -- ${shQuote(path)}`); + if (failure) return failure; + const gone = (p: string) => p === path || p.startsWith(`${path}/`); + this.projectFiles = this.projectFiles.filter((p) => !gone(p)); + this.projectDirs = this.projectDirs.filter((p) => !gone(p)); + this.openFiles = this.openFiles.filter((file) => !gone(file.path)); + if (gone(this.selectedFile)) this.selectedFile = this.openFiles.at(-1)?.path ?? ''; + return null; + } + + /** + * Opens `path` as a tab (or focuses its existing tab) and makes it the active + * file. A preview open reuses the current preview tab's slot instead of adding + * a tab; a permanent open pins the tab. An already-open tab keeps its pin + * state on a preview open, so focusing tabs never re-previews them. + */ + async openFile(path: string, preview = false): Promise { + if (!this.pod || !this.podReady || this.unmounted) return; + const existing = this.openFiles.find((file) => file.path === path); + if (existing) { + if (!preview) existing.preview = false; + if (this.selectedFile === path) return; + this.flushActive(); + this.selectedFile = path; + return; + } + this.flushActive(); + this.loading = true; + this.selectedFile = path; + try { + const content = await readPodFile(this.pod, `${this.workdir}/${path}`); + if (this.unmounted || this.openFiles.some((file) => file.path === path)) return; + // A pin that arrived while the read was in flight wins over the preview flag. + const entry: OpenFile = { + path, + content, + savedContent: content, + preview: preview && !this.pendingPins.delete(path) + }; + const previewIndex = entry.preview ? this.openFiles.findIndex((file) => file.preview) : -1; + this.openFiles = + previewIndex >= 0 + ? this.openFiles.map((file, i) => (i === previewIndex ? entry : file)) + : [...this.openFiles, entry]; + } catch (error) { + console.error('Failed to load file:', error); + this.pendingPins.delete(path); + if (this.selectedFile === path) this.selectedFile = this.openFiles.at(-1)?.path ?? ''; + } finally { + this.loading = false; + } + } + + /** Promotes a preview tab to a permanent one; safe to call while the tab is still loading. */ + pinFile(path: string): void { + const entry = this.openFiles.find((file) => file.path === path); + if (entry) entry.preview = false; + else this.pendingPins.add(path); + } + + /** Closes a tab, flushing unsaved edits first; the neighbour tab (next, else previous) becomes active. */ + closeFile(path: string): void { + const index = this.openFiles.findIndex((file) => file.path === path); + if (index < 0) return; + const entry = this.openFiles[index]; + if (entry.content !== entry.savedContent) void this.saveEntry(entry); + this.openFiles = this.openFiles.filter((file) => file.path !== path); + if (this.selectedFile === path) + this.selectedFile = (this.openFiles[index] ?? this.openFiles[index - 1])?.path ?? ''; + } + + /** Best-effort save of the active tab before it loses focus, so a pending autosave can't be lost. */ + private flushActive(): void { + const file = this.activeFile; + if (file && file.content !== file.savedContent) void this.saveEntry(file); + } + + async saveFile(path = this.selectedFile): Promise { + const entry = this.openFiles.find((file) => file.path === path); + if (entry) await this.saveEntry(entry); + } + + private async saveEntry(entry: OpenFile): Promise { + // Saving only makes sense once the dev server is reachable; earlier writes + // would race the template hydration. + if (!this.pod || !this.hasPortal || this.unmounted) return; + if (this.savingPaths.has(entry.path)) return; + this.savingPaths.add(entry.path); + this.isSaving = true; + const content = entry.content; + try { + await writePodFile(this.pod, `${this.workdir}/${entry.path}`, content); + entry.savedContent = content; + } catch (error) { + console.error('Failed to save file:', error); + } finally { + this.savingPaths.delete(entry.path); + this.isSaving = this.savingPaths.size > 0; + } + } + + /** + * Spawns an interactive bash shell into a freshly mounted terminal element. + * One call per terminal tab. The shell runs until pod teardown even if its + * tab is closed. + */ + async startBash(el: HTMLElement): Promise { + if (!this.pod || !this.podReady) return; + try { + const terminal = await this.pod.createDefaultTerminal(el); + void this.pod.run('bash', ['-i'], { terminal, cwd: this.workdir }); + } catch (error) { + console.error('Failed to start bash:', error); + } + } + + shutdown(): void { + this.unmounted = true; + this.bootToken += 1; + if (this.pod) void shutdownPod(this.pod); + } +} + +/** Single-quotes a path for `bash -c`*/ +function shQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +async function fetchManifest(config: FrameworkConfig): Promise { + const response = await fetch(`${config.sourceRoot}/manifest.txt`); + if (!response.ok) { + throw new Error( + `Failed to load manifest for ${config.id}: ${response.status} ${response.statusText}` + ); + } + const text = await response.text(); + return text + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); +} + +async function fetchTemplateFile(config: FrameworkConfig, fileName: string): Promise { + const response = await fetch(`${config.sourceRoot}/${fileName}`); + if (!response.ok) { + throw new Error( + `Failed to fetch ${fileName} for ${config.id}: ${response.status} ${response.statusText}` + ); + } + return response.arrayBuffer(); +} + +// shutdown() is not part of the published type definitions yet. +async function shutdownPod(pod: BrowserPod): Promise { + try { + await (pod as BrowserPod & { shutdown?: () => Promise }).shutdown?.(); + } catch (error) { + console.error('Failed to shut down pod:', error); + } +} diff --git a/src/lib/stores/leaveWarning.svelte.ts b/src/lib/stores/leaveWarning.svelte.ts new file mode 100644 index 0000000..fc225d4 --- /dev/null +++ b/src/lib/stores/leaveWarning.svelte.ts @@ -0,0 +1,36 @@ +export const leaveWarningState = $state<{ open: boolean; pendingPath: string }>({ + open: false, + pendingPath: '' +}); + +// A `window.location.href` assignment triggers the browser's own `beforeunload` event just like +// a refresh or a closed tab does. Pages that listen for it (to catch tab-close/refresh/back- +// forward) call `consumeIntentionalNavigation()` there to skip their dialog for navigations we +// triggered ourselves — the in-app leave-warning modal already asked, so a second native prompt +// right after would just be a confusing duplicate. +let intentionalNavigation = false; + +export function markIntentionalNavigation() { + intentionalNavigation = true; +} + +export function consumeIntentionalNavigation(): boolean { + const wasIntentional = intentionalNavigation; + intentionalNavigation = false; + return wasIntentional; +} + +/** + * Navigates via a full page reload (the pod-teardown mechanism — see CLAUDE.md), unless leaving + * an active agent session, in which case it asks for confirmation first since the running + * terminal's work would otherwise be lost without warning. + */ +export function navigateWithLeaveGuard(path: string, isActiveSession: boolean) { + if (isActiveSession) { + leaveWarningState.pendingPath = path; + leaveWarningState.open = true; + return; + } + markIntentionalNavigation(); + window.location.href = path; +} diff --git a/src/lib/stores/stepper.svelte.ts b/src/lib/stores/stepper.svelte.ts index 1d86c47..ab798a9 100644 --- a/src/lib/stores/stepper.svelte.ts +++ b/src/lib/stores/stepper.svelte.ts @@ -1 +1,12 @@ -export const stepperState = $state({ open: false }); +export const stepperState = $state({ open: false, step: 1 }); + +// The single entry point for opening the tour — always resets to slide 1 first. A plain +// `stepperState.open = true` from outside Stepper.svelte can't reliably trigger a reset from +// inside it: Stepper.svelte's `currentStep` was a local reactive statement watching +// `stepperState.open`, but legacy `$:` blocks in a non-runes component only re-run off their +// component's own `let` dependencies, not off external $state proxy reads — so it missed +// external opens (e.g. from the sidebar's Help menu) after a prior run had advanced past slide 1. +export function openTour() { + stepperState.step = 1; + stepperState.open = true; +} diff --git a/src/lib/utils/main.ts b/src/lib/utils/main.ts index 282aaf6..d63dbcb 100644 --- a/src/lib/utils/main.ts +++ b/src/lib/utils/main.ts @@ -1,3 +1,4 @@ +import type { BinaryFile, BrowserPod, Terminal } from '@leaningtech/browserpod'; import { cliConfigs, toolItems } from '$lib/config/tools'; import { trackEvent } from './useLazyTracking'; @@ -61,7 +62,8 @@ export async function bootCLI( await copyFile(pod, config.projectFile, homePath, filename); } - terminal.write(`Starting ${toolLabel}...\n`); + // write() exists at runtime but is missing from the published Terminal types + (terminal as Terminal & { write: (data: string) => void }).write(`Starting ${toolLabel}...\n`); trackEvent('Booted', { tool: toolLabel }); @@ -73,15 +75,7 @@ export async function bootCLI( } export async function copyFile( - pod: { - createFile: ( - path: string, - mode: 'binary' | 'text' - ) => Promise<{ - write: (data: ArrayBuffer | string) => Promise; - close: () => Promise; - }>; - }, + pod: BrowserPod, path: string, prefix: string, destFilename?: string @@ -89,7 +83,7 @@ export async function copyFile( const normalizedPrefix = prefix.endsWith('/') ? prefix.slice(0, -1) : prefix; const dest = destFilename ? `${normalizedPrefix}/${destFilename}` : `${normalizedPrefix}/${path}`; - const file = await pod.createFile(dest, 'binary'); + const file = (await pod.createFile(dest, 'binary')) as BinaryFile; const resp = await fetch(path); if (!resp.ok) { diff --git a/src/lib/utils/tabLock.ts b/src/lib/utils/tabLock.ts new file mode 100644 index 0000000..ddd157c --- /dev/null +++ b/src/lib/utils/tabLock.ts @@ -0,0 +1,55 @@ +export type TabLock = { + /** Resolves true once this tab holds the exclusive lock, false if another tab already does. */ + acquired: Promise; + /** Releases the lock so another tab can claim it. Safe to call even if never acquired. */ + release: () => void; +}; + +/** + * Claims an exclusive, tab-lifetime lock so only one browser tab can hold `name` at a time — + * used to stop two tabs from booting the same agent against the same BrowserPod storage key. + * Falls back to always-acquired when the Web Locks API isn't available, so unsupported browsers + * fail open instead of being blocked by a check they can't run. + * + * Waits (rather than failing instantly) for `graceMs` before concluding another tab holds the + * lock: on a reload/navigation, the previous tab's lock release and this tab's request can race, + * and a plain `ifAvailable` check would misreport that race as "another tab is open". + */ +export function requestSingleTabLock(name: string, graceMs = 500): TabLock { + if (typeof navigator === 'undefined' || !navigator.locks) { + return { acquired: Promise.resolve(true), release: () => {} }; + } + + let release: () => void = () => {}; + const held = new Promise((resolve) => { + release = resolve; + }); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), graceMs); + + const acquired = new Promise((resolveAcquired) => { + navigator.locks + .request(name, { mode: 'exclusive', signal: controller.signal }, async (lock) => { + clearTimeout(timeoutId); + resolveAcquired(lock !== null); + if (lock !== null) { + await held; + } + }) + .catch((err: unknown) => { + const isTimeout = err instanceof DOMException && err.name === 'AbortError'; + // Timed out waiting => another tab genuinely still holds it. Anything else is an + // error we can't diagnose, so fail open rather than block booting on it. + resolveAcquired(!isTimeout); + }); + }); + + return { + acquired, + release: () => { + clearTimeout(timeoutId); + release(); + } + }; +} diff --git a/src/lib/utils/useLazyTracking.js b/src/lib/utils/useLazyTracking.js deleted file mode 100644 index 641a01d..0000000 --- a/src/lib/utils/useLazyTracking.js +++ /dev/null @@ -1,20 +0,0 @@ -let trackEventImpl = null; - -(async () => { - try { - trackEventImpl = (eventName, props = {}) => { - if (typeof window !== 'undefined' && window.plausible) { - window.plausible(eventName, { props }); - } - }; - } catch (err) { - console.warn('Failed to load EventTracking:', err); - trackEventImpl = () => {}; - } -})(); - -export function trackEvent(eventName, props = {}) { - if (trackEventImpl) { - trackEventImpl(eventName, props); - } -} diff --git a/src/lib/utils/useLazyTracking.ts b/src/lib/utils/useLazyTracking.ts new file mode 100644 index 0000000..da42433 --- /dev/null +++ b/src/lib/utils/useLazyTracking.ts @@ -0,0 +1,13 @@ +type PlausibleProps = Record; + +declare global { + interface Window { + plausible?: (eventName: string, options?: { props?: PlausibleProps }) => void; + } +} + +export function trackEvent(eventName: string, props: PlausibleProps = {}) { + if (typeof window !== 'undefined' && window.plausible) { + window.plausible(eventName, { props }); + } +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index f07a4c0..f87b293 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -4,38 +4,48 @@ import Sidebar from '$lib/components/Sidebar.svelte'; import UtilityBar from '$lib/components/UtilityBar.svelte'; import Stepper from '$lib/components/Stepper.svelte'; + import LeaveWarningModal from '$lib/components/LeaveWarningModal.svelte'; import IosUnsupportedModal from '$lib/components/IosUnsupportedModal.svelte'; import { page } from '$app/stores'; import { toolItems } from '$lib/config/tools'; + import { stepperState } from '$lib/stores/stepper.svelte'; let { children } = $props(); - const validToolIds = new Set(toolItems.filter((t) => !t.disabled).map((t) => t.id)); - const defaultTool = toolItems.find((t) => !t.disabled)?.id ?? 'claude'; + // The tour's "star us" slide (step 6) points at this ribbon, so it needs to sit above the + // tour's backdrop for that one step only — back below it (its normal spot, under the sidebar + // flyouts) the rest of the time. + let ribbonAboveTour = $derived(stepperState.open && stepperState.step === 6); - let activePanel = $derived( - validToolIds.has($page.params.tool as string) ? $page.params.tool : defaultTool - ); + const validToolIds = new Set(toolItems.filter((t) => !t.disabled).map((t) => t.id)); - let activeTool = $derived(toolItems.find((t) => t.id === activePanel)); + let activeTool = $derived( + $page.route.id === '/agents/[tool]' && $page.params.tool && validToolIds.has($page.params.tool) + ? toolItems.find((t) => t.id === $page.params.tool) + : undefined + ); - let pageTitle = $derived(activeTool ? `${activeTool.label} — BrowserCode` : 'BrowserCode'); + let pageTitle = $derived( + activeTool + ? `${activeTool.label} — BrowserCode` + : $page.route.id?.startsWith('/ide') + ? 'Playground IDE — BrowserCode' + : $page.route.id === '/agents' + ? 'Agents — BrowserCode' + : 'BrowserCode — Start coding on your browser tab' + ); let pageDescription = $derived( activeTool ? `Run ${activeTool.label} in your browser, on BrowserCode.` - : 'Run AI coding CLIs in your browser.' - ); - - let pageUrl = $derived( - activeTool ? `https://browsercode.io/${activeTool.id}` : 'https://browsercode.io' + : $page.route.id?.startsWith('/ide') + ? 'Build and preview web apps right in your browser, on BrowserCode.' + : $page.route.id === '/agents' + ? 'Use your favorite CLI agents without any installations, sandboxed.' + : 'BrowserCode runs a full Node.js sandbox in WebAssembly — no installs, no servers.' ); - function handlePanelToggle(panel: string) { - if (validToolIds.has(panel)) { - window.location.href = `/${panel}`; - } - } + let pageUrl = $derived(`https://browsercode.io${$page.url.pathname}`); @@ -58,12 +68,17 @@
+ - + +