Repository navigation
Feat/editor tabs - #40
Merged
Merged
Conversation
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.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this changes
Which layer does this touch?
.packages/@do-md/core/) — GPL-3.0How it was verified
Contributor License Agreement
automated check asks.