Skip to content

feat(editor): editor tabs — multiple documents per window - #38

Merged
do-md merged 8 commits into
do-md:feat/editor-tabsfrom
Systemofdumb:pr/editor-tabs
Sep 13, 2026
Merged

do-md merged 8 commits into
do-md:feat/editor-tabsfrom
Systemofdumb:pr/editor-tabs

Conversation

@Systemofdumb

Copy link
Copy Markdown
Contributor

Implements #29. Seven commits on main.

Each tab owns an EditorStore for as long as it is open — created when the tab opens, dropped when it closes, never on a switch. Switching remounts the view over the other tab's existing store via the store prop, so it is an attach rather than a re-parse, and undo history, selection and scroll survive it.

The registry is a plain Map on the tab store rather than part of its state: that state goes through immer, which freezes, and a frozen EditorStore is an inert one. Nothing renders from the map, so it does not need to be reactive.

It removed more than it added — the markdown-serialising bridge, the docEpoch generation counter, the flush-save on switch, the manual scroll save/restore, and the save-handle threading are all gone. Tab is down to id, meta, dirty and two disk-staleness fields.

Your list

Tab bar renders nothing at all with a single tab — not hidden, absent, so the flex column never allots the row. A one-document window is identical to before tabs existed.

Per-tab dirty and untitled state reported to Rust. update_tabs carries path, dirty, and content — the last only for tabs that are dirty AND never saved, since those are the only ones the sheet could be asked to review. One helper answers "does this window hold work no autosave will ever write?", and all three gates consult it: CloseRequested, the queue dirty_untitled_labels builds, and the per-window recheck inside the quit review. A window can no longer close over unsaved work in a tab you cannot see.

Close flow. ⌘W with two or more tabs closes the active tab; on the last tab it goes through the ordinary window close, so the native sheet stays the single authority — and it now sees every tab.

Shortcuts and menus. ⌘N opens a tab, ⇧⌘N a new window, both as menu entries with en/zh/ja labels. Navigation is ⌘⇧[ / ⌘⇧] only, matched on code as you asked; ⌘1–⌘9 are gone and nothing claims ⌘T.

The close prompt, per your spec: an in-app modal with Don't Save / Cancel / Save, Escape cancelling and Enter defaulting to Save, with the sheet's grouping. Window close and quit still go through the native sheet.

Readiness, as you asked

Both activate-tab emit sites now clear WindowReady, and the frontend asserts readiness once the document is in place — through one markReady helper used by every route into the window.

The part that makes clearing safe on that route is asserting it even when nothing changed. Rust cannot tell "switch to a background tab" from "re-activate the tab already showing"; previously clearing on the second case would have left the CLI waiting for a mark no remount would produce, which is why the route cleared nothing at all. The host now marks ready unconditionally, so the ambiguity stops mattering.

Measured that specific risk rather than assuming it: re-opening the already-active tab and inserting immediately completes in 0.05s with the text landing correctly — not the ~5s a stranded readiness wait would cost.

Being straight about what I could not show: I could not get the underlying race to reproduce on either build. Two domd-cli invocations are slower than the tab switch, so open followed by insert lands correctly with and without this change. The fix is therefore correct by construction and verified not to strand, rather than demonstrated against a failing baseline. If you have a tighter way to drive the socket, that gap is worth closing.

Background sync stays out, as agreed — additive once the registry exposes the per-tab stores.

Verification

Harnesses: verify-block-format 188, verify-ai-blocks 100, verify-scroll-store 14, verify-tab-runtime 5 — all passing. tsc clean for app and kernel, cargo check clean, and eslint output byte-identical to main (59 files, same counts).

The close modal has its own suite driven in both WebKit and Chromium — 18 assertions covering Don't Save closing the tab, Cancel keeping it and returning focus to the editor, Escape cancelling, and repeated prompt/cancel cycles leaving exactly one editor shell.

That last assertion exists because of a bug worth flagging for the file rather than the diff: I first keyed the modal by the tab being closed, which is the same value that keys DOMDProvider (version is the active tab id). Closing the active tab put two siblings under one key, and React mounted a second editor shell without removing the first — two stacked documents, no error, since the duplicate-key warning is development-only. The modal is unkeyed now, with a comment saying why.

Re-ran your Backspace harness against this branch after rebasing: alive on both engines, in the plain web build and with the tabbed desktop path booted.

Still macOS-only testing — no Windows or Linux machine here, so those paths remain unexercised.

Systemofdumb and others added 7 commits September 12, 2026 22:24
Adds a tab strip to the desktop editor so one window can hold several
documents. Web is unchanged.

## Approach

Rather than a second editor shell, the tab store becomes the document
source that `useDocumentLoaders` reads from. Its public API is unchanged —
meta, content, version, view and one entry point per source — so
`editor-app` is untouched apart from mounting the tab bar and passing
`embedded` to `Editor`.

That falls out of something the editor already does: `key={version}`
remounts the kernel whenever a document is replaced. A tab switch is that
same event with a different document arriving, so tabs inherit the existing
editor tree whole — collaboration, disk reconciliation, the outline panel,
find & replace, AI, the titlebar bridge — with no parallel copy to keep in
step. Web is simply the case where nothing ever opens a second tab.

## Per-document subsystems

Only the active tab has a mounted editor, so:

- Collaboration binds to the active tab's docId, and a tab switch runs the
  same teardown as loading a different file into the window. The room record
  survives in collab.db and resumes when its tab returns.
- Disk reconciliation stays with the active editor. For background tabs the
  watcher records staleness only; on activation a clean tab re-reads from
  disk and a dirty one gets a forced reconcile, so an external edit merges
  rather than overwrites.
- Documents load through the existing frontmatter/domd-id pipeline, so tabs
  keep document identity and the block never reaches the editor.

## Not losing work

- The outgoing editor flushes a save on tab switch — autosave is debounced
  600ms and dies with the unmount.
- Closing a dirty, never-saved tab asks first.
- The window's assigned path follows the active tab (new `clear_window_path`
  command), so the native "save changes?" sheet still speaks for whatever is
  actually on screen.

Window close is deliberately left to the existing Rust handler; registering
a JS `onCloseRequested` listener moves window destruction into JavaScript
and breaks the close button.

## Rust

`WindowTabs` tracks each window's open paths. `open_or_reuse` routes a file
into a tab of the focused window and activates an existing tab when the file
is already open. Cmd+N / Cmd+W act on tabs. The file watcher polls every open
tab rather than only each window's last-assigned document.

## Notes

Frontend and Rust build clean; eslint output is identical to v0.8.1 with
zero added or removed issues. Verified on macOS: two files opening as tabs in
one window, re-opening activating the existing tab, and a background tab
picking up an external edit on activation.
Each tab now owns a document runtime for as long as it is open. It is
created when the tab opens and dropped when the tab closes, never on a
switch; switching remounts the VIEW over another tab's existing store via
the kernel's bring-your-own-store seam.

The registry is a plain Map on TabStore rather than part of its state:
immer freezes state, and a frozen EditorStore is an inert one. Nothing
renders from it directly — components read the runtime for the active tab
id — so it does not need to be reactive.

This deletes more than it adds. Gone: tab-editor-bridge entirely, along
with serialize-to-markdown on unmount, the docEpoch generation counter that
guarded write-back against a stale buffer, the flush-save on switch, and the
manual scroll save/restore. `Tab` loses `content`, `scrollTop` and
`docEpoch` — there is no snapshot to go stale, so nothing needs a counter to
guard it. Undo history, selection, focus and scroll survive a switch because
they live in the store and the kernel restores the last three on attach.

The mount key is now the tab id alone. Replacing the document inside a tab
resets the runtime in place instead of remounting, because remounting would
discard the very view state the kernel just preserved.

resolveUnsaved collapses to one path: the runtime holds the live document
whether or not a view is attached, so a background tab needs no snapshot and
the active tab needs no detour through the mounted editor's save handle —
which removes the saveRef threading through useTabs.

Construction options move to a single createRuntime factory in EditorApp,
since DOMDProvider ignores them once handed a store.
⌘Z did nothing once you had switched tabs. The undo history was intact the
whole time — store.undo() worked — the keystroke simply never reached the
kernel, which binds undo/redo and the rest of its key handling to the
editable root rather than to window.

The first attempt used store.focus(). That records focus INTENT for the
render layer to materialize, which is right for gestures mid-session but not
across a view remount, which is what a tab switch is. Observed after a
switch: document.activeElement WAS the editor root, store.focused_ was true,
a synthetic keydown dispatched at that root undid correctly — and real
keystrokes still did nothing until something called .focus() on the element.

So focus the element, through the kernel's own DOM context rather than by
querying for a kernel-internal attribute. Only on an actual switch: the
empty -> first tab transition is not one, which keeps upstream's deliberate
no-autofocus on first load and stops the initial load running a pointless
collaboration teardown.

Also adds scripts/verify-tab-runtime: a headless check that a
hand-constructed EditorStore — the way the tab registry builds one — has a
working undo stack. That is what ruled the registry out as the cause.
Tab bar renders nothing at all with a single tab — no strip, no border, no
reserved height, so a one-document window looks exactly as it did before
tabs existed.

Per-tab dirty/untitled state reported to Rust. `update_tabs` now carries
path, dirty and (only for tabs that are dirty AND never saved) content, and
one helper answers "does this window hold unsaved work no autosave will ever
write, and what should the sheet show?" — consulted by CloseRequested, by
the quit review's queue, and by its per-window recheck. Tab state is
authoritative once reported, with the old per-window snapshot as the
fallback for a window that has not reported yet. A window can no longer
close over unsaved work sitting in a tab you cannot see.

Cmd+W on the last tab now goes through the ordinary window close rather than
force_close_window, so the native sheet stays the single authority. Closing
one tab among several still asks here, since the sheet speaks for a window
and has no per-tab counterpart.

Cmd+N opens a tab, Shift+Cmd+N a new window, both as menu entries with
en/zh/ja labels.

Tab navigation keeps Cmd+Shift+[ / ] only, and now matches on `event.code`:
with Shift held the bracket keys report "{" and "}", so the previous
`event.key === "["` test could never have fired. Cmd+1–9 removed — those
belong to the heading commands in @do-md/commands. The modifier is
platform-appropriate rather than metaKey-only, and the whole hook is
desktop-gated.
`domd-cli close` checked the window-level dirty flag, which under tabs
reflects the ACTIVE document. Since it then calls destroy() — deliberately
bypassing CloseRequested — an unsaved, never-saved document in a background
tab could be discarded by a close the caller believed was safe.

Uses the same gate as the window close flow, so it accounts for every tab.
Saved documents stay closable (autosave already wrote them) and --force is
unchanged.

Verified: a window whose visible tab is a clean saved file now refuses to
close while a hidden tab holds unsaved work, and --force still overrides.
Closing one tab among several has no native counterpart — the macOS sheet
speaks for a window — so that path asked in-app through the `ask` plugin
dialog, which offers two buttons. There was no Cancel: once you triggered a
tab close you could not call it off, on the one path that can destroy work.

An in-app modal instead, with the sheet's semantics rather than its chrome:
Don't Save / Cancel / Save, Escape cancels, Enter saves, and the sheet's
grouping (the irreversible choice set apart on the left). Enter is not bound
by hand — the default button takes focus, so it falls out of ordinary button
behaviour and Tab order and assistive tech get the right answer for free.
Window close and quit are untouched: both still go through the native sheet.

The close flow is async and the answer comes from a rendered component, so
the resolver parks in a ref. Two guards around that: a second close request
while a prompt is open is ignored rather than stranding the first one's
resolver, and unmounting with a prompt open resolves it as cancel — the only
outcome that touches neither the document nor the disk.

Cancel restores focus to what it was, because the editor stays mounted and
focus is otherwise left on a button that no longer exists; the kernel binds
its key handling to the editable root, so typing would silently do nothing
until you clicked back in. Save and discard leave that to TabFocusOnSwitch,
which owns focus for the document that comes forward.

The modal is deliberately unkeyed, and the comment says why: the obvious key
is the tab id, which is exactly what keys the DOMDProvider — closing the
ACTIVE tab then puts two siblings under one key, and React mounts a second
editor shell without removing the first. Two stacked documents, no error,
since the duplicate-key warning is development-only.

Verified in both WebKit and Chromium, 18 assertions: Don't Save closes the
tab, Cancel keeps it and returns focus to the editor, Escape cancels, and
repeated prompt/cancel cycles leave exactly one editor shell. Labels added
under tabs.unsavedSave / unsavedDontSave / unsavedCancel in en/zh/ja. tsc
clean; eslint output identical to the parent commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`open_or_reuse` clears WindowReady before routing a file to a window, so an
`insert` arriving immediately after `open` waits rather than landing in the
document that is still on screen. That relied on the editor remount to put
readiness back, and under tabs neither route remounts dependably.

The activate-tab route could not clear it at all. Rust cannot tell "switch to
a background tab" from "re-activate the tab already showing", and clearing in
the second case left the CLI waiting five seconds for a mark no remount would
ever produce — so it cleared nothing, and `open` on a file already in a
background tab raced with the switch.

Both routes clear it now, and the frontend asserts readiness once the document
is actually in place — including the case where nothing changed, which is what
makes clearing safe on the ambiguous route. That is the same
`invoke("benchmark_mark_ready")` the reuse path already used, lifted into one
helper so every route into the window goes through it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Sep 12, 2026

Copy link
Copy Markdown

@Systemofdumb is attempting to deploy a commit to the do-md's projects Team on Vercel.

A member of the Team first needs to authorize it.

Double-clicking a `.md` while the app was closed showed an empty Untitled
document instead of the file. Opening one while it was already running worked,
as did `domd-cli open` and passing the path as an argument.

A cold Finder open is the only route where nothing is listening yet. `setup`
creates an empty window, RunEvent::Opened then lands here while the webview is
still booting, and the `open-file-in-tab` emit arrives before the frontend has
registered for it. The one-document build survives that because its reuse path
records the window's assigned document in WindowFiles first, and the frontend's
first act is to ask `get_my_path` what it is meant to show. The tab rewrite
kept the emit and dropped the write, so the answer was null and the window
blanked itself.

Records it before emitting again. The event stays as the live path for a
window that is already up; the entry is what makes a cold start deterministic
rather than a race. The two converge rather than conflict — `addTab` dedupes
on path, so a window that both hears the event and reads the entry activates
one tab, not two.

Verified on a built app across every route: cold Finder open loads the file,
warm Finder open adds a tab, re-opening a file already in a background tab
activates it with the right content, `domd-cli open` is unchanged, and a plain
launch with no file still opens blank.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Systemofdumb

Copy link
Copy Markdown
Contributor Author

Pushed a fix for a regression this branch introduced, caught in daily use rather than by any of my tests.

Double-clicking a .md while the app was closed opened an empty Untitled document instead of the file. Every other route was fine — opening one while the app was already running, domd-cli open, and passing the path as an argument all worked, which is exactly why it survived this long.

A cold Finder open is the only route where nothing is listening yet: setup creates an empty window, RunEvent::Opened lands while the webview is still booting, and the open-file-in-tab emit arrives before the frontend has registered for it.

main survives that because its reuse path writes the window's assigned document into WindowFiles before emitting, and the frontend's first act is to ask get_my_path what it is meant to show. I kept the emit and dropped the write, so the answer was null and the window blanked itself. The event was never the durable half of that handshake — the entry was.

Restored, before the emit. The two converge rather than compete: addTab dedupes on path, so a window that both hears the event and reads the entry activates one tab, not two.

Verified on a built app across every route — cold Finder open loads the file, warm Finder open adds a tab, re-opening a file already in a background tab activates it with the right content, domd-cli open unchanged, and a plain launch with no file still opens blank.

Worth saying plainly: none of my harnesses would have caught this. They drive the frontend directly and never exercise a cold launch, so the window where the emit can be missed does not exist for them. Worth a look at whether that gap is worth closing more generally, since it is the same shape as the readiness race we discussed — state that only exists as an in-flight event is state a slow boot can lose.

@do-md
do-md changed the base branch from main to feat/editor-tabs September 13, 2026 11:47
@do-md
do-md merged commit 3c51542 into do-md:feat/editor-tabs Sep 13, 2026
1 of 2 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 13, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants