The owner editor at /u/<username>/projects/<id> is the single most-used surface in memo. It became a real IDE chrome around Monaco in v3.x — the workflow you'd expect from VS Code-in-the-browser: folder tree, tabs, find-in-files, diff view, quick-open, status bar.
The shell composes ten React islands (src/components/editor/EditorShell.tsx is the orchestrator):
lucide-react icons + uppercase labels.DiffEditor instead of Editor when diff mode is on.<mark> highlights.Ln:Col from Monaco cursor, language, encoding.All state lives in EditorShell (useState graph in one place — open tabs, per-file buffers, dirty set, blob URLs for image previews, original-content cache for diffs, etc). Saves go to the staging API (/api/owner/projects/<id>/files/...); publish triggers the server-side esbuild + R2 commit + version-row insert (or skips entirely when no file changed — see Publish Pipeline).
CSS grid: 56px <sidebarWidth>px 1fr with a drag-resize handle on the sidebar's right edge (200–500px). The activity bar pins the first column, the active sidebar panel (or nothing if collapsed) fills the second, the editor area fills the rest. Claude assistant stays a fixed-position pill in the bottom-right (out of grid flow).
┌────┬───────────────────┬──────────────────────────────────────┐
│ ▤ │ EXPLORER │ ┌─ tabs ──────────────────────┐ ✦ │
│ ⌕ │ Investment Memo │ │ index.html • style.css │ │
│ ◷ │ + New ↑ Draft ↓4 │ ├──── toolbar (status+save+publish) │
│ │ │ │ │ │
│ │ ▾ src/ │ │ Monaco │ │
│ │ ▸ index.html │ │ (or DiffEditor in diff mode) │ │
│ │ ▸ app.ts │ │ │ │
│ │ ◇ style.css │ │ │ │
│ │ ▾ assets/ │ │ │ │
│ │ ▢ hero.png │ │ │ │
│ │ ¶ README.md │ ├── status bar ───────────────┤ │
│ │ │ │ Ln 42, Col 8 • TypeScript │ │
└────┴───────────────────┴───┴──────────────────────────────┴────┘
└─ Activity bar (Files/Search/Changes, ⌘1/⌘2/⌘3)
┌─────────────────────────────────────────────────────────────┐
│ Project · test-1 [Delete project]│
├──┬───────────────┬──────────────────────────────────────────┤
│ │ Project tabs │
├──┴──┬────────────┴──────────────────────────────────────────┤
│ File│ ┌────────────────────┐ ┌──────────────────────┐ ✦ │
│ tree│ │ Monaco │ │ Live preview │ │
│ │ │ index.html │ │ (sandboxed iframe) │ │
│ │ │ app.ts │ │ │ │
│ │ │ │ │ ┌─ /api/mcp called │ │
│ │ └────────────────────┘ └──────────────────────┘ │
└─────┴────────────────────────────────────────────────────────┘
Three activities, switched via ⌘1 / ⌘2 / ⌘3 or the rail icons. Clicking the currently-active one collapses the sidebar (returns it to a 56px-only IDE view, maximising editor space).
| Glyph | Kind | Panel content |
|---|---|---|
FilesIcon | explorer | Project name header + action chip row (+ New, Upload, ↓ Draft, ↓ v{N}) + <FileTree> |
SearchIcon | search | <SearchPanel> — find-in-files |
GitCompareIcon | changes | <ChangesPanel> — git-status-style file list with diff-on-click |
ActivityBar shows a yellow badge on the Changes icon when there are uncommitted changes (count of added + modified + removed files). The count comes from <ChangesPanel onChangeCount={…}> bubbling up.
<FileTree> (FileTree.tsx) takes a flat paths: string[] and renders a hierarchical tree. Folders come before files at every level; both alphabetical within level.
buildTree(paths) is a pure exported function (vitest-tested) that builds the recursive { kind: "dir" | "file", name, path, depth, children? } shape.Set<string> of expanded directory paths. Root is expanded by default. State is per-render-cycle (no localStorage; per-project state is server-driven and we don't want stale UI leaking across projects).▸ TS/JS, ● HTML, ◇ CSS, ◆ JSON, ¶ MD, ▢ image, · other. Cheap, brutalist-aesthetic-consistent, no SVG bundle bloat.is-active modifier (memo-yellow background).tabIndex={0} on the <ul>).onCreateAt(dir) so the new-file prompt pre-fills the path.<EditorTabs> (EditorTabs.tsx) is presentational — all state lives in EditorShell. The shell owns:
openFiles: string[] — tab order.activeFile: string | null — which tab is focused.buffers: Map<string, string> — text contents per open file. Buffers PERSIST across tab switches: switching from index.html to app.ts and back doesn't refetch.dirty: Set<string> — paths with unsaved edits.previewUrls: Map<string, string> — blob URLs for binary images / SVG.Behaviours:
openFiles, append + fetch into buffers. Set activeFile.buffers[activeFile], add to dirty.dirty, bump changesRefreshKey so the Changes panel re-fetches.window.memo.confirm; otherwise drop the buffer, set activeFile to the previous tab.useEffect on activeFile calls scrollIntoView({ inline: "nearest", block: "nearest" }) on the active tab's DOM ref. Long tab strips don't strand the active tab off-screen.Keyboard:
⌘W close active tab (with dirty confirm)⌘Shift+] next tabOwned by the shell so the shortcuts fire even when the tab strip doesn't have focus.
<SearchPanel> ([SearchPanel.tsx`) — debounced cross-file substring/regex search.
| Field | Behaviour |
|---|---|
| Query input | Debounced 300ms via useRef<timer>. Empty query cancels in-flight fetch via AbortController and clears results. |
| Regex toggle | When on, the backend compiles new RegExp(query, flags) with a try/catch; backend returns 400 { error: "invalid_regex" } on syntax error which the panel surfaces inline. |
| Case-sensitive toggle | Backend uses "g" vs "gi" flags accordingly. |
| Results | Grouped by file (collapsible header). Each row shows <lineno>: <snippet> with <mark> around the match span (client-side regex highlight separate from the server-side match — same regex flags so highlighting matches the find logic). |
| Status line | 12 matches in 3 files / no matches / truncated — refine your query if truncated: true in the response. |
Click a result row → calls onJumpTo(path, line, col) on the shell. The shell:
openFile(path).setTimeout(80) (lets Monaco mount its model for the new file), grabs monacoRef.current and calls editor.revealLineInCenter(line) + editor.setPosition({ lineNumber: line, column: col }) + editor.focus().The backend endpoint is POST /api/owner/projects/<id>/search (see API → projects v3.x). Per-file cap 50 matches, global cap 500, rate-limit 30/min per (user, project), audit-logged as project.search.
<ChangesPanel> (ChangesPanel.tsx) — git-status equivalent for memo.
Sections rendered: Added (in staging, not in current version), Modified (in both, different sha256), Removed (in version, not in staging). Each row shows a status badge (green A, yellow M, red D) + the file path. Click → emits one of:
onOpenDiff(path, "added" | "modified") — opens the file in a tab AND fetches the published version's content into originalContents: Map<string, string> (cache; cleared on publish). Sets diffMode = true.onOpenVersionFile(path, versionId) — for removed files (no staged side). Loads the published content directly into the staged buffers so the user can copy-paste it back.The Changes panel re-fetches on save and publish via a refreshKey counter the shell bumps. Also publishes a changesCount callback so the activity bar's Changes icon shows a count badge.
Diff toolbar toggle: when the active file has a cached originalContents entry, a Diff / Edit button appears in the editor toolbar. Toggle swaps Monaco's <Editor> for <DiffEditor> with original = published content, modified = staged buffer, originalEditable: false. Read-only on the left, the staged buffer remains editable on the right (changes also propagate to buffers + dirty as normal).
Endpoint pair: GET /api/owner/projects/<id>/changes (the diff list) + GET /api/owner/projects/<id>/versions/<versionId>/files/<...path> (the original file bytes). See API.
<QuickOpen> (QuickOpen.tsx) — modal fuzzy file picker. Triggered by ⌘P / Ctrl+P anywhere in the editor.
Scoring (score(path, query) in the component):
1000 - basename.length (strongest signal).500 - path.length.100 - path.length.-1, excluded.Among equals, shorter paths win. Top 50 results render; empty query shows the first 50 paths alphabetically. Keyboard: ↑↓ navigate, Enter pick, Esc close.
<StatusBar> (StatusBar.tsx) — bottom strip with:
● modified in yellow when dirty.has(activeFile)).Ln <n>, Col <m>) — driven by Monaco's onDidChangeCursorPosition callback into shell state cursor: { line, col }.languageForPath(activeFile)).UTF-8 today; reserved for future per-file detection).Lazy-mounted via @monaco-editor/react. Language inferred per file extension:
| Extension | Language |
|---|---|
.ts, .tsx | typescript |
.js, .jsx, .mjs | javascript |
.css | css |
.html, .htm | html |
.json, .map | json |
.md | markdown |
.svg, .xml | xml |
| everything else | plaintext |
The onMount callback stashes the editor instance on monacoRef.current and wires onDidChangeCursorPosition to update the shell's cursor state (powers the status bar). The buffer is held in buffers: Map<string, string> keyed by path. Save PUTs the buffer to the staging API at /api/owner/projects/<id>/files/<path> (audit-logged as project.file.write). Publish kicks the server-side esbuild compile + R2 commit pipeline (see Publish Pipeline).
Binary images (image/png, image/jpeg, image/gif, image/webp, image/avif, image/x-icon) get a preview pane, not Monaco. The shell:
Blob, creates URL.createObjectURL(blob), stashes it in previewUrls: Map<string, string>.<img src={url}> inside a checkerboard-backed container (so transparency is visible).Raw toggle in the toolbar — clicking switches to Monaco's read-only view of the bytes (no save — readOnly: true on the Editor, plus shell-level saveDisabled if isBinaryImage).SVG is a special case (binary image MIME but text-renderable): the buffer holds the source text AND a blob URL is built from it for preview mode. SVG saves work normally.
When a project opens, pickDefaultFile(paths) (exported from EditorShell for testability) chooses which file to focus first:
README.md (case-insensitive)*.mdindex.html*.html / *.htm*.txt| Shortcut | Action |
|---|---|
| ⌘P / Ctrl+P | Open Quick-open file picker |
| ⌘S / Ctrl+S | Save active file (skips binary images with a toast) |
| ⌘W / Ctrl+W | Close active tab (confirms if dirty) |
| ⌘Shift+] | Next tab |
| ⌘Shift+` | Previous tab |
| ⌘1 / ⌘2 / ⌘3 | Switch activity panel: Explorer / Search / Changes |
| (Tree focused) ↑↓ | Navigate visible rows |
| (Tree focused) ←→ | Collapse / expand folder, or jump to parent |
| (Tree focused) Enter | Select file or toggle folder |
| (Tree focused) Esc | Blur tree |
Right pane of the editor split. Renders the active file as it would look in production, debounced ~400 ms after typing stops.
| File type | Behavior |
|---|---|
.html, .htm | Rendered raw in a sandboxed iframe. |
.ts, .tsx, .js, .jsx, .mjs | Compiled in-browser via esbuild-wasm, then loaded as an ES module inside a one-shot iframe HTML stub. |
| anything else | "No preview" placeholder. |
[src/lib/editor/compile-wasm.tslazy-initialises esbuild-wasm the first timetransformTs` is called:
await esbuild.initialize({
wasmURL: `https://unpkg.com/esbuild-wasm@${version}/esbuild.wasm`,
worker: true,
});
The wasm binary is fetched from unpkg once per session and cached. We only use esbuild.transform (not build), so there's no bundler / no virtual filesystem — compile-on-save translates the active file in isolation. Output is ESM, ES2020, with an inline source map.
The preview iframe gets sandbox="allow-scripts" — no same-origin. That means:
We accept the tradeoff that the preview iframe doesn't share session with memo proper. Since this is an authoring environment, not a runtime, that's fine.
A floating yellow ✦ pill bottom-right opens a side panel with a prompt textarea and a streaming draft pane.
POST /api/owner/ai/generate
Content-Type: application/json
{
"projectId": "<uuid>",
"path": "index.html",
"currentSource": "...",
"prompt": "Add a hero section with a yellow CTA button"
}
requireOwner(context) — Clerk-gated, same as every other /api/owner/* route.ANTHROPIC_API_KEY isn't set, return 503 anthropic_api_key_unset.@anthropic-ai/sdk is imported dynamically — that way the route's cold start is fast for the common case of just reading staged files; the SDK only loads when an actual generation is fired.ai.generate row with the path, prompt length, and model.messages.stream and pipe content_block_delta text_delta events into a ReadableStream of Uint8Array. The client reads it incrementally and re-renders the draft pane on each chunk.System prompt forces Claude to return file contents only — no markdown fences, no chatter — so the "Replace file →" button can pipe the streamed text straight into the Monaco buffer.
Currently claude-sonnet-4-6. The right model depends on the task; for small file rewrites Sonnet is the right balance. Easy to swap in the API route's MODEL constant.
When a project is shared (see Collaboration), the editor turns into a real-time multi-cursor surface — VS Code Live Share semantics, built on Yjs.
The useCollab hook (src/components/editor/useCollab.ts) binds the active file's Y.Text (room key file:<path>) to the Monaco model via y-monaco's MonacoBinding. That gives:
It is wired into EditorShell additively and gated on a realtime prop. With realtime off the hook is inert and the editor is byte-for-byte the single-user surface documented above — zero regression. The transport is chosen at runtime (Liveblocks or Cloudflare Durable Objects) by MEMO_REALTIME_PROVIDER; the editor never knows which — see Architecture → Collaboration & realtime.
Persistence rides the existing save path. Yjs is only the live concurrency authority: remote edits land in the Monaco model exactly as local typing does, so they flow through the same onChange → buffers → ⌘S save → publish pipeline (Publish Pipeline). The server save remains the durable snapshot; nothing about staging or versions changes.
EditorShell
├── refreshFiles() ──────────► GET /api/owner/projects/<id>/files
│ ↓
│ setFiles([...])
│ setActiveFile(first)
│
├── (selecting a file) ─────► GET /api/owner/projects/<id>/files/<path>
│ ↓
│ setContents(text)
│
├── save() ─────────────────► PUT /api/owner/projects/<id>/files/<path>
│ ↓ (audit project.file.write)
│ toast ok
│
├── createFile() ────────────► memoPrompt() → PUT (empty body)
│ ↓
│ refreshFiles(), setActiveFile(path)
│
├── uploadFiles(FileList) ────► multipart POST /api/owner/projects/<id>/upload
│ ↓ (audit project.upload, unzipper for .zip)
│ refreshFiles(), toast
│
├── publish() ────────────────► POST /api/owner/projects/<id>/publish
│ ↓ (server esbuild → R2 → versions row)
│ toast "Published vN — Mfiles — Tms"
│
├── ClaudeAssistant ─────────► POST /api/owner/ai/generate (streaming text/plain)
│ ↓ user clicks "Replace file"
│ setContents(draft)
│ setMessage("Replaced by Claude — Save to persist")
│
└── LivePreview ─────────────► esbuild-wasm transform (worker)
↓
iframe.srcdoc = <stub><script type=module>...</script></stub>
.ts and .js sidecars and publish becomes a no-op pass-through. Pending UX validation; it changes the publish-only semantics.