Skip to content

Feat/editor tabs - #40

Merged
do-md merged 10 commits into
mainfrom
feat/editor-tabs
Sep 13, 2026
Merged

do-md merged 10 commits into
mainfrom
feat/editor-tabs

Conversation

@do-md

@do-md do-md commented Sep 13, 2026

Copy link
Copy Markdown
Owner

What this changes

Which layer does this touch?

  • Application layer (macOS app, web app, plugins, helper libraries) — MIT
  • Editor kernel (.packages/@do-md/core/) — GPL-3.0

How it was verified

Contributor License Agreement

  • I have read the CLA and will sign it on this pull request when the
    automated check asks.

Systemofdumb and others added 10 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>
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>
feat(editor): editor tabs — multiple documents per window
…review

Post-merge hardening of editor tabs (#38): every finding from the
line-by-line review, one follow-up feature, one regression caught in use.

Unsaved work (the data-loss class):
- update_tabs ships content for EVERY dirty tab and flags the active one.
  Same-value dirty marks are immer no-ops (no state change, no push), so
  the snapshot Rust held for the save sheet froze at the moment a tab
  first turned dirty - a quit-time "Save" then wrote that near-empty
  snapshot over the user's document. Rust now overlays the active tab
  with the fresher update_content stream, and the tab layer refreshes
  the snapshot on a trailing debounce while dirty.
- Path-backed dirty tabs ship the FULL file (frontmatter reattached);
  flush_dirty_saved_tabs writes them on window close, quit and CLI close
  - the autosave whose debounce timer died with the switched-away view.
  Byte-identical writes are skipped so other editors' conflict detection
  stays quiet.
- Closing a dirty path-backed tab flushes instead of discarding
  silently; useAutoSave flushes its pending save on unmount, through a
  meta-neutral path - reporting meta back would stamp the outgoing
  document's meta onto the incoming active tab.
- The native close flow reviews EVERY dirty never-saved tab, one sheet
  each, before destroying the window (present_save_sheet + chained
  review_untitled_then_destroy; each continuation runs after the
  previous sheet's doc teardown on the same main-thread tick).
  CloseRequested, the quit queue and domd-cli close share the gates.

Document lifecycle:
- Replacing the document inside a tab builds a FRESH runtime and bumps a
  per-tab docEpoch that keys the editor mount. Resetting in place kept
  the previous document's undo stack alive against the new tree (undo
  after File > New spliced old content or threw), left the autosave
  first-tick guard consumed (opening a file wrote it straight back,
  bumping mtime), and skipped the collaboration teardown on the
  blank-tab-reuse path (the incoming file's content was pushed into the
  old document's room). A switch is still an attach - the re-parse is
  only paid when a new document actually loads.
- rereadTauriDoc shares the first read's ensureDomdId pipeline, so an
  external rewrite that dropped the frontmatter no longer strips the
  document's identity on re-read.
- The dirty-background-tab reconcile is now real: DiskReconciler
  consumes a `stale` prop and runs the forced pass itself. The previous
  reconcileEpoch counter was read by nothing, so the flag was cleared
  and the external edit overwritten by the next autosave.

Readiness and window plumbing:
- Readiness is asserted by the view again: the editor's post-commit RAF
  mark covers every route that remounts, and the host marks only the
  route where nothing changes. Marking from the host before React
  commits handed CLI inserts to the outgoing document's still-bound
  listener.
- saveDocument no longer assigns the window path; the activePath effect
  is the single owner, so saving a background tab through the close
  prompt cannot retitle the window after a dying document.
- Cmd+W routes through Rust's own tab count: two or more tabs go to the
  frontend close-tab handler, otherwise the ordinary window close - the
  shortcut can no longer go dead while the frontend boots. Close Window
  moves to Shift+Cmd+W.
- Tab drag reorder re-measures after the commit (layout effect keyed on
  the new order) instead of reading the pre-reorder layout.

Also:
- Dropping .md files on a window opens them as tabs, same routing as a
  Finder open: activate the existing tab, reuse the lone blank one, else
  a new tab. Multi-file drops open one tab each, sequentially.
- TabFocusOnSwitch focuses with preventScroll: the raw focus() call
  scroll-into-viewed the contenteditable root one frame after the
  kernel's scroll-memory restore, and the resulting scroll event then
  overwrote the stored anchor with the top of the document.
- Web no longer pays a full toMarkdown serialization every 150ms for a
  dirty flag nothing reads (the isTauri guard moved back above it).
- Dead scaffolding removed: the unreachable open-file listener, the
  tabId prop and scroll-container marker, the unused tabs.untitled
  strings, the stale TabEditorBridge references.
- New harness scripts/verify-tab-store (15 assertions) covers the tab
  store's replacement/switch/dirty/close semantics. The resolve hooks
  learned to load JSX-free .tsx modules, and zenith's immer Patch
  imports went type-only so the store can run under Node type stripping.
@vercel

vercel Bot commented Sep 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
domd Ready Ready Preview Sep 13, 2026 1:40pm UTC

@do-md
do-md merged commit e118a4a into main Sep 13, 2026
3 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 13, 2026

This branch was successfully deployed

1 active deployment
Preview — dbb5c9a1 Deployed Sep 13, 2026 by vercel[bot]
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.

3 participants