Skip to content

Feature proposal: editor tabs (multiple documents per window) #29

Closed

Description

@Systemofdumb

I've been running a local branch that adds editor tabs — several documents in one window — and it's reached a state where it might be worth upstreaming. Before opening a PR I wanted to ask whether you'd want the feature at all, and if so in what shape, since it touches the document model.

Branch: https://github.com/Systemofdumb/domd/tree/pr/editor-tabs (on top of v0.8.1)

What it does

A tab strip on the desktop editor: open several documents in one window, drag to reorder, Cmd+Shift+[/] and Cmd+1..9 to navigate, close with the tab's ⨯ or middle-click. Files opened from Finder or the CLI land in a tab of the focused window, and re-opening a file already open activates its tab instead of duplicating it. Web is unchanged.

Approach

The part I'd most like your opinion on.

My first attempt was a second editor shell for desktop that mirrored the existing one. Upgrading it from v0.5.0 to v0.8.1 showed why that doesn't hold — the per-document tree had gained ModeController, TitlebarBridge, LocalAiBridge and AiCollab, and mirroring them would have to happen every release, with anything missed silently absent from the tabbed build.

So instead there is still one editor shell, and tabs sit underneath it. A tab store becomes the document source that useDocumentLoaders reads from; its public API is unchanged, so editor-app is untouched apart from mounting the tab bar and passing embedded to Editor. This leans on something the editor already does: key={version} remounts the kernel when a document is replaced, and a tab switch is that same event with a different document arriving. Tabs therefore inherit the existing tree whole — collaboration, disk reconciliation, the outline panel, find & replace, AI — and web is simply the case where nothing opens a second tab.

Net effect on your files is small: editor-app.tsx +42 lines, editor.tsx +43. Everything else is new files plus the Rust side.

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 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.

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 on screen.

One thing I got wrong and want to flag, since it's a trap: I briefly registered a JS onCloseRequested listener to prompt for background tabs. That moves window destruction into JavaScript (if (!evt.isPreventDefault()) await this.destroy()) while the Rust close handler is running its own flow, and it left the close button dead. Window close is left entirely to the existing Rust handler now. The residual gap is a background tab holding never-saved work when the window closes — the native sheet can only speak for one document.

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.

State

Frontend and Rust build clean, and eslint output is identical to v0.8.1 — 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. Only macOS has been exercised.

Happy to open this as a PR and sign the CLA if you're interested, and equally happy to rework the approach or drop it if tabs aren't a direction you want. Understood either way — it's a decent chunk of surface area for a single-maintainer project.

Activity

  1. do-md commented on Sep 7, 2026

    @do-md
    Owner

    Thanks for asking first, and for the thoughtful write-up. Yes — I’d like tabs, and I think keeping a single editor shell is the right direction. The part I’d change is what survives underneath that shell when switching tabs.

    There’s one fact about the kernel that isn’t documented anywhere and may change how you approach that part of the implementation.

    The kernel store runs headless

    EditorStore has no DOM dependency. You can construct one in bare Node and it works — toMarkdown(), replaceRanges(), undo/redo, and the whole model layer. The DOM only enters at the render surface.

    Some numbers from a 20k-line / 595 KB document: the constructor returns in ~8 ms, the document finishes loading ~780 ms later through idle-scheduled chunking, and it costs ~42 MB of heap, scaling linearly.

    The change I’d like: one store per tab

    Rather than having only one live document at a time, I’d like each tab to own a long-lived EditorStore — the document runtime. It holds the model, undo stack, and dirty state, and can later own or reference the collaboration session as well.

    The store is created when a tab opens and destroyed when the tab closes, never when switching tabs. The single mounted editor then becomes a view over the active tab’s runtime.

    Most of the behavior follows naturally from that:

    • A switch becomes an attach, not a re-parse. Those ~780 ms are no longer paid every time you return to a large document.
    • Undo history and cursor state survive switching because they live in the store, and the store doesn’t go anywhere.
    • It provides the foundation for background documents to remain fully live — watching disk and broadcasting to collaborators even when they aren’t on screen.
    • It also makes split view possible later: two panes over two documents, or two views onto the same document. That can’t happen while a document exists only inside the single mounted editor.

    So I agree with keeping one editor shell. The difference is that a tab switch should hand the Provider another tab’s existing store rather than hand it another document to parse into a newly constructed store.

    Detaching and reattaching the view is the kernel’s responsibility, and neither store should be recreated or reset in the process. I’ll confirm the exact mechanics once the relevant kernel interface lands, so there’s no need to depend on a particular teardown behavior for now.

    What I’ll add to the kernel first

    There’s currently no way to hand an existing store to the editor: DOMDProvider only accepts initMd, and the underlying store Provider always constructs its own store.

    I’ll add an optional store to both. If one is passed, it will be used as-is; if not, the current behavior will remain unchanged.

    Alongside that, I’ll add view-state restoration to the kernel — caret, focus, and scroll position, anchored to the document in the same way the cursor already is. Attaching a view to a store will restore all three, so the tab implementation won’t need to manage them itself.

    Both packages are checked into this repository, so once the change is on main, you’ll be able to build against it without waiting for an npm release. I’ll comment here when it’s ready.

    What I’d like this PR to cover

    • The runtime registry and tab lifecycle, as described above, including view attachment and detachment when switching tabs.

    • Tab bar UI. With a single tab, it should render nothing at all — no strip, no border, and no reserved height. A one-document window should look exactly as it does today.

    • Per-tab dirty and untitled state reported to Rust, so the existing three-button Save / Don’t Save / Cancel sheet and the ⌘Q review can account for tabs that aren’t currently visible. This is the part I care most about getting right: closing a window must not be able to discard unsaved work in a background tab.

    • Shortcuts and menus.

      • ⌘N / Ctrl+N should open a new tab in the current window; ⇧⌘N / Ctrl+Shift+N should open a new window. I’d like both actions available from the menu, with matching en/zh/ja labels.
      • Use ⌘⇧[ / ⌘⇧] for tab navigation. One thing to be aware of is that ⌘1–⌘6 and ⌘0 already belong to the heading/body commands in @do-md/commands (keymap.ts), while ⌘T inserts a table. Both keymap systems listen on window during the bubble phase, so collisions are currently resolved by component mount order rather than by a deterministic rule.
      • Shortcuts should be desktop-only and use the platform-appropriate modifier rather than checking only metaKey.
      • With two or more tabs, ⌘W should close the active tab. With only one tab remaining, it should close the window. This should continue through the existing Rust close flow so the native confirmation sheet remains the single authority for close confirmation.
    • Rust. The per-(label, path) handling you introduced in file_watch.rs is structurally what I want, so please keep it. Two nearby details are worth watching:

      • open_or_reuse should deterministically choose the currently focused window; the iteration order of all_windows() is arbitrary.
      • benchmark_mark_ready fires every time the editor mounts, not only on the first mount. The window’s readiness state therefore needs to be cleared when a file is opened into a tab. Otherwise, an immediate insert following domd-cli open may run against the previous document.
    • Find and outline state do not need special per-tab handling. The current Provider boundaries already keep panel open/closed state at the window level, while the panel contents rebuild from the active document.

    An optional question about background sync

    Separately — and this is not a requirement for the tabs PR — I’d like your take on whether the background-live work fits naturally into this architecture.

    Would you be interested in extending it so every open tab remains live in the background?

    The behavior I have in mind is:

    • Watch the file associated with every open tab, not only the active one.
    • When a file changes on disk, apply the change to that tab’s EditorStore, whether or not the tab is visible.
    • If that document has an active shared collaboration session, pass the resulting update through its collaboration layer and broadcast it over WebRTC.
    • Allow the collaboration/WebRTC layer to keep sessions for multiple stores active at the same time and route incoming updates to the correct document.

    The benefit is that once a document has been shared, it stays current regardless of whether its tab is active. Whether a change comes from the local editor, the filesystem, or another collaborator, everyone sees it promptly without requiring someone to switch back to that tab first.

    If this feels like too much scope for the PR, that’s completely fine. The core tab work doesn’t need to depend on it, and I can add the background watching and multi-store WebRTC support afterward. I’d mainly like your opinion on whether it fits naturally into the architecture you’re building now.

    Please push back

    This is my current view of the design, not a fixed specification. If you see a better decomposition or think any part is more elaborate than tabs actually require, please say so.

    You’ve spent more time in this area recently than I have, and I think the design will benefit from that discussion.

    The same applies if something turns out to be significantly harder than it appears once you’re implementing it. I’d rather revisit the design than have you work around it.

  2. do-md commented on Sep 7, 2026

    @do-md
    Owner

    One additional point: I’m aware that keeping a long-lived store for every open tab may become a memory concern when many large documents are open. This is already part of the longer-term plan. One direction is to safely hibernate or evict the stores of tabs that have been inactive for a long time and reconstruct them when needed. Another is to move away from each store eagerly loading the entire document: stores will eventually load document content by chunk and keep their memory usage bounded. So the current per-store memory cost isn’t the intended long-term scaling model, although it’s acceptable as a first step.

  3. Systemofdumb commented on Sep 7, 2026

    @Systemofdumb
    ContributorAuthor

    Thanks — that's a much better decomposition than what I had, and the kernel detail changes the picture considerably.

    Store per tab

    Agreed, and I'd go further: it's simpler than what I built, not more elaborate.

    The weakness in my version that I should have called out and didn't — undo history doesn't survive a tab switch. The bridge serializes to markdown, the Provider remounts on key={version}, and the new store starts with an empty undo stack. Caret position goes the same way; I restored scroll offset and nothing else. That's not a rough edge, it's a defect, and it's inherent to "a tab switch is a document swap" rather than something I could have patched around.

    Once each tab owns a long-lived store, most of my machinery stops having a reason to exist:

    • serialize-to-markdown on unmount — unnecessary, the store persists
    • the docEpoch generation counter guarding write-back against a stale buffer — unnecessary, there's no stale buffer
    • the flush-save on switch (autosave is debounced 600 ms and dies with the unmount) — unnecessary, nothing is at risk
    • the split between "clean background tab re-reads from disk wholesale" and "dirty background tab gets a forced reconcile" — collapses into one path, since a background tab has a live store to reconcile into

    So the registry replaces more code than it adds, and the ~780 ms re-parse disappears with it. Happy to build against store on DOMDProvider plus view-state restoration once they're on main — and glad view state is landing in the kernel, because anchoring caret restoration from the tab layer is exactly the kind of thing that ends up subtly wrong.

    The two Rust issues

    Both correct, both fixed on the branch (fe9d718).

    benchmark_mark_ready. You're right that it fires on every mount. My code had the opposite assumption written into a comment — that adding a tab wouldn't remount the editor, so clearing the flag would strand the CLI. open_or_reuse now clears WindowReady before emitting open-file-in-tab. Verified with your exact case, open immediately followed by insert and no delay between them: the text lands in the newly opened document and the previous file is untouched on disk. It also explains a symptom I'd been misreading as unrelated — domd-cli open intermittently returning not_ready — which is gone now that readiness is cleared and the remount re-marks it.

    Window selection. Fixed: labels are sorted by their w<N> creation index, and both the focused lookup and the fallback walk that order, so the fallback is "most recently opened window" rather than a hash ordering. That also removes the ambiguity if more than one window ever reports focus.

    A third instance I left alone deliberately. The same race exists on the activate-tab path when the requested file is already open in a background tab: that switch remounts the editor too, so readiness is briefly stale. I didn't fix it because Rust can't distinguish "switching to a background tab" from "re-activating the tab that's already active", and clearing in the second case would leave the CLI waiting five seconds for a mark that never comes. It seems to want a signal from the frontend, which is new command surface I'd rather you shape than have me invent. Worth folding into the readiness story when the store registry lands, since attach/detach is the natural place for it.

    Keymap arbitration

    Dropping ⌘1–⌘9 for tab navigation is fine — ⌘⇧[ / ⌘⇧] only, and I'll make the shortcuts desktop-only and platform-correct rather than testing metaKey.

    But I'd push back on treating the collision as a tabs problem. If both keymap systems listen on window during the bubble phase and conflicts resolve by component mount order, that's non-deterministic today and tabs are only the first consumer to make it visible. Working around it means the next feature that wants a binding rediscovers the same thing. A deterministic priority or ownership rule seems worth having on its own; I'm happy to avoid the contested keys either way, but it felt worth naming rather than quietly routing around.

    Close flow

    Understood, and this corrects something in my branch: closing the last tab currently calls force_close_window, which destroys the window and bypasses the sheet entirely. Under your model that becomes an ordinary window close with the sheet as the single authority, and per-tab dirty/untitled state reported to Rust is what lets the sheet and the ⌘Q review account for tabs that aren't visible.

    Related, and the reason I agree this is the part to get right: I briefly had a JS onCloseRequested listener to prompt for background tabs, and it broke the close button outright. Registering one moves window destruction into JavaScript — if (!evt.isPreventDefault()) await this.destroy() — while the Rust handler is running its own flow, so a handler that prevents or throws leaves nobody to close the window. Reporting state to Rust and leaving the decision there is clearly the right shape.

    Background sync

    Architecturally it fits, and it's specifically the store registry that makes it possible: a background document needs a live runtime to receive a disk change and hand it to its collaboration layer at all. Under my current design there's nothing there to receive it.

    I'd keep it out of this PR though. Multi-store session routing is a different problem from tab lifecycle, and I'd rather the tab work be reviewable on its own than have both land together. If the registry exposes the per-tab stores cleanly it should be additive afterwards.

    Sequencing

    The registry is blocked on your kernel change, but a few things aren't, so I can make progress meanwhile:

    • the two Rust fixes (done)
    • tab bar rendering nothing at all with a single tab
    • per-tab dirty/untitled reported to Rust, and routing ⌘W-on-last-tab through the normal close flow
    • shortcuts and menus, including ⌘N / ⇧⌘N and the en/zh/ja labels

    Then the registry and view attach/detach once store and view-state restoration are on main.

    Two caveats worth stating plainly: only macOS has been exercised, and I don't have a Windows or Linux machine to check the non-mac paths. And on memory — your follow-up matches how I'd think about it; a store per open tab seems a reasonable first step given eviction and chunked loading are on the roadmap, and it's bounded by how many documents a person actually keeps open.

  4. do-md commented on Sep 8, 2026

    @do-md
    Owner

    The kernel changes are on main, in the checked-in packages.

    What I added

    store on the Provider. DOMDProvider and the underlying store Provider now take an optional store. When given, it is used as-is and every construction-time prop (initMd, placeholder, tokenizers, inline rules, …) is ignored — the store already carries them. The Provider never constructs, resets, or destroys a store it was handed. Without the prop, behavior is unchanged, byte for byte.

    View-state restoration. Detach and re-attach is now an explicit kernel protocol — that settles the caveat I gave earlier about not depending on teardown details. Detach unbinds and nothing else; attach re-binds and restores. The store carries three axes of persistent view-state, and a view mounting over a store restores all of them:

    • Selection — replayed exactly as recorded, range selections included, anchored by identity (block/span uuid + offset) the same way the cursor already is, so it survives edits that happen while no view is attached (collab, AI, disk reconcile).
    • Focus — restored to what it was at detach. A store that was blurred when its view went away comes back blurred; restoration never steals focus and never invents a caret.
    • Scroll — an identity-addressed anchor (first visible block/span + pixel delta), captured continuously on the store and restored on attach. Exposed as store.scrollAnchor / setScrollAnchor.

    The three axes are independent: the anchor owns the viewport and the selection replay carries no scroll command, so a caret that was off-screen at detach stays off-screen, exactly as the user left it.

    Restoration only runs when the store has state to restore — a fresh store's first mount is unchanged. Repeated detach/attach cycles, including with blur/focus history in between, are exercised and hold up.

    The two open threads

    • Readiness: agreed the activate-tab race wants a frontend signal, and attach/detach now exists as a concrete kernel moment. I'll pick up the command-surface discussion in your PR.
    • Keymap arbitration: you're right that it isn't a tabs problem. I'm taking the deterministic-priority question into its own issue so this PR doesn't have to carry it.
  5. Systemofdumb commented on Sep 8, 2026

    @Systemofdumb
    ContributorAuthor

    The registry is built against the new kernel seams and working. Branch updated: https://github.com/Systemofdumb/domd/tree/pr/editor-tabs (rebased onto main, three commits).

    Store per tab

    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.

    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 directly, so it does not need to be reactive.

    It removed considerably more than it added, as you expected:

    • the whole bridge component that serialized to markdown on unmount
    • the docEpoch generation counter that guarded write-back against a stale buffer
    • the flush-save on switch (autosave is debounced 600 ms and died with the unmount)
    • the manual scroll save/restore, now the kernel's
    • the save-handle threading, because 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

    Tab is down to id, meta, dirty, and two disk-staleness fields. The mount key is the tab id alone: replacing the document inside a tab resets the runtime in place rather than remounting, since remounting would discard the view state the attach protocol just restored.

    Scroll and selection restoration work exactly as advertised, and undo now survives a switch.

    The two Rust issues

    Both fixed and verified.

    benchmark_mark_ready — cleared in open_or_reuse before routing a file. Verified with your exact case, open immediately followed by insert: the text lands in the newly opened document and the previous file is untouched on disk.

    Window selection — labels sorted by their w<N> creation index, with both the focused lookup and the fallback walking that order, so the fallback is the most recently opened window rather than a hash ordering.

    Readiness needs a frontend signal after all — and here is why

    This turned out to be sharper than the activate-tab case we discussed. Reusing the lone blank tab no longer remounts anything: the runtime is reset in place, precisely so view state survives. So clearing readiness in open_or_reuse left nothing to re-mark it, and open started returning not_ready.

    The tab layer now re-arms readiness once the document is actually in place, because it is the only party that knows — it is true for both the reuse and the new-tab branch. That is invoke("benchmark_mark_ready") from the frontend, no new command surface, but it does mean readiness is now a thing the host asserts rather than something that falls out of a mount. Worth deciding whether that is the shape you want before it hardens, and the activate-tab case can then use the same signal.

    One observation from the attach protocol

    Restoring focus is the host's job — agreed, and the tab layer does it, since selecting a tab means "I want to work in this document" and the kernel binds its key handling to the editable root.

    The part worth reporting: doing it through store.focus() was not sufficient across a view remount. After a switch, document.activeElement was the editor root and store.focused_ was true, a synthetic keydown at that root undid correctly — and real keystrokes still did nothing until something called .focus() on the element itself. Calling it on the element (through useEditorDom) fixed it.

    I am not claiming a kernel bug: in this path the store was legitimately blurred at detach, since clicking the tab bar moves focus there, so restoration correctly declined to steal it. But the gap between "model says focused" and "the browser will deliver keys here" is worth knowing about if other hosts start attaching views.

    Separately: undo was never reachable on desktop

    While chasing the above I found that ⌘Z, ⇧⌘Z and ⌘A do nothing in the desktop app, and so do the matching Edit menu entries — in an ordinary single-document window, nothing to do with tabs. The predefined Edit menu items carry the standard key equivalents, so macOS claims the keys and sends undo:/selectAll: down the responder chain instead of letting them reach the editable root.

    Filed as #30 with a fix branch, since it affects every desktop user independently of whether tabs land.

    Still open from your list

    Not started yet, all independent of the kernel: tab bar rendering nothing at all with a single tab; per-tab dirty and untitled state reported to Rust, with ⌘W-on-last-tab going through the normal close flow; and the ⌘N / ⇧⌘N menu work with the en/zh/ja labels.

    One caveat to repeat: everything here is macOS-only testing. I have no Windows or Linux machine, so the non-mac paths in this branch are unexercised.

  6. Systemofdumb commented on Sep 8, 2026

    @Systemofdumb
    ContributorAuthor

    The four items are done and on the branch: https://github.com/Systemofdumb/domd/tree/pr/editor-tabs (five commits on main).

    Tab bar

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

    Per-tab dirty and untitled state

    update_tabs now 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 and the only ones whose text Rust has any reason to hold.

    One helper answers "does this window hold work no autosave will ever write, and what should the sheet show?", and all three gates consult it: CloseRequested, the queue dirty_untitled_labels builds, and the per-window recheck inside the quit review. Tab state is authoritative once the frontend has reported it; the old per-window snapshot stays as the fallback for a window that has not reported yet.

    Verified end to end: a window whose visible tab is a clean saved file now refuses to close while a hidden tab holds unsaved work, and closing the same window with force still succeeds.

    Close flow

    ⌘W with two or more tabs closes the active tab. On the last tab it goes through the ordinary window close rather than force_close_window, so the native sheet stays the single authority — and it now sees every tab, not just the visible one.

    Shortcuts and menus

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

    Two things worth reporting from that work:

    The navigation shortcuts could never have fired. They tested event.key === "[", but with Shift held the bracket keys report { and }. They match on event.code now, with a platform-appropriate modifier rather than metaKey only, and the hook is desktop-gated.

    domd-cli close could discard unsaved work. It checked the window-level dirty flag — which under tabs reflects only the active document — and then calls destroy(), deliberately bypassing CloseRequested. So a never-saved document in a background tab could be thrown away by a close the caller believed was safe. It uses the same gate now; --force is unchanged.

    One interaction decision I would like your view on

    Closing a single tab among several has no native counterpart — the sheet speaks for a window — so that path asks in-app. Tauri's ask gives two buttons, so the prompt is currently Save / Don't save with no Cancel: once you have triggered the tab close you cannot abort it.

    That is a deviation from the three-button sheet everywhere else, and from what you would expect on macOS. The fix is a small in-app modal rather than the plugin dialog, but it is your product's interaction design, so I would rather ask than pick. Window close and quit are unaffected — both still go through the native sheet.

    State

    Frontend and Rust build clean, and eslint output is identical to main with zero added or removed issues. Tested on macOS: tab bar absent at one tab, ⌘W closing the active tab and prompting for a never-saved one, the close gate refusing over a hidden tab's unsaved work, and undo/selection/scroll surviving a switch.

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

    Separately, #30 (the Edit menu swallowing ⌘Z / ⇧⌘Z / ⌘A) is not on this branch: it is not tab-related and stands on its own.

  7. do-md commented on Sep 8, 2026

    @do-md
    Owner

    Thanks for taking it this far, and for the reports along the way. The navigation
    shortcuts never being able to fire, and domd-cli close bypassing the tab gate,
    are both things I wouldn't have caught.

    On the close prompt: your read is right, and I'd go with the in-app modal. Three
    buttons — Save / Don't Save / Cancel — with Escape cancelling and Enter defaulting
    to Save. A destructive prompt you can't back out of is worse than one that isn't
    quite native chrome, and matching the sheet's semantics matters more here than
    matching its looks.

    On #30: I'm working on it. Thanks for splitting it out — you're right that it
    stands on its own.

  8. Systemofdumb commented on Sep 9, 2026

    @Systemofdumb
    ContributorAuthor

    The close prompt is done as you specified, and the branch is updated: https://github.com/Systemofdumb/domd/tree/pr/editor-tabs (six commits on main).

    The modal

    Three buttons — Don't Save / Cancel / Save — with Escape cancelling and Enter defaulting to Save, and the native sheet's grouping: the irreversible choice set apart on the left, away from the cluster your hand is already heading for. Window close and quit are unchanged; both still go through the sheet.

    Enter is not bound by hand. The default button takes focus on mount, so Enter-saves falls out of ordinary button behaviour and Tab order and assistive tech get the right answer without special cases.

    Two details from wiring an async close flow to a rendered component. The resolver parks in a ref, so 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 whatever had it before the prompt. It has to: the editor stays mounted, focus is sitting on a button that is about to disappear, and the kernel binds its key handling to the editable root — so without it the next keystroke goes to <body> and typing silently does nothing until you click back in. Save and discard deliberately skip it, since both close the tab and focus for the document that comes forward belongs to the switch path.

    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.

    One trap worth recording

    I shipped this with the modal keyed by tab id and it corrupted the window: closing a tab left the old document on screen above the new one, permanently, and the app kept working around the corpse.

    DOMDProvider is keyed by version, which is the active tab id. Keying the modal by the tab being closed therefore puts two siblings under the same key whenever you close the active tab — which is the common case. React then mounts a second editor shell without removing the first.

    What made it expensive to find is that it is completely silent in a production build: the duplicate-key warning is development-only, so there is no error, no exception, and nothing in any log. I spent several rounds on plausible wrong theories — that the editor's unmount was unsafe in WKWebView, then that it was a repaint artifact — before instrumenting the running app to count editable roots in the DOM and finding two live editor shells under the app's root column, with zero errors recorded.

    The modal is unkeyed now, like the other modals in that file, with a comment saying why. Flagging it because the tab id is exactly what anyone would reach for next, and this file already has a keyed provider sitting next to a list of conditionally-rendered modals.

    #33 will shape how tabs look to you

    Please take #33 before reviewing this branch, or it will look like the tab work is broken.

    Backspace as the first keystroke in an empty document permanently kills the editor — no caret, no placeholder, nothing typed afterwards is ever inserted, and no error. It reproduces on main in WebKit with no tabs in the path; Chromium is unaffected, which is why it is invisible in a browser and reliable in the shipped macOS app. A new window opens on exactly that state, so it is "new window, hit Backspace by reflex".

    It matters here because tabs remount the editor on every switch, and unmounting an editor in that state throws removeChild — which aborts React's commit and leaves the old tree stranded on screen. So upstream the bug is a quietly dead editor; with tabs it is a visibly wrecked window within seconds. Same bug, and the reproduction script in #33 needs none of this branch.

    Also outstanding

    Both independent of tabs, both with branches: #31 (letter shortcuts matched by physical key position, so ⌘W runs Undo on any non-QWERTY layout) and #32 (set_locale rebuilding the menu bar with an identical copy, leaving the discarded menu's key equivalents live).

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

  9. do-md commented on Sep 11, 2026

    @do-md
    Owner

    #33 is fixed and on main — run your harness, the empty-doc backspace should be gone. #30/#31/#32 are all merged too, so the ground's clear to review the branch properly.

    Readiness as a host-asserted signal works for me; use the same invoke(benchmark_mark_ready) for activate-tab. Background sync — agreed, keep it out, it's additive later.

    Open it as a PR and I'll do a full review pass.

  10. do-md commented on Sep 13, 2026

    @do-md
    Owner

    Tabs are on main and shipped in v0.9.0 — thank you for carrying this through every round of spec changes. They went through feat/editor-tabs first so I could work through my review on a branch before main auto-deployed it.

    The review turned up real problems, fixed in dbb5c9a — the commit message carries the full list. The class that mattered was unsaved work:

    • update_tabs only re-ran when the tabs array changed identity, and re-marking a dirty tab is an immer no-op that notifies nobody — so the content Rust held for the save sheet froze at the first keystroke, and a quit-time Save wrote that near-empty snapshot over the document.
    • first_unsaved_content reviewed one tab and the flow then destroyed the window; the other never-saved tabs died unprompted.
    • The switch-time flush was deleted, but resolveUnsaved and handle_close still reasoned "autosave already wrote it". And reconcileEpoch was read by nothing — same shape as the dead reconcile pass in the first branch.

    Worth naming the pattern: every one of these lived in a seam — immer's same-value short-circuit, React's commit timing (markReady raced the remount), destroy() bypassing CloseRequested. The harnesses prove the parts in isolation; none of them touched tab state, which is what your cold-start Finder regression was telling us from the other direction. scripts/verify-tab-store starts covering that layer.

    The architecture held up: one runtime per tab is exactly why every fix stayed local. Background sync stays open as discussed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions