feat: independent native windows with menu-based tab transfer#214
Open
PathGao wants to merge 12 commits into
Open
feat: independent native windows with menu-based tab transfer#214PathGao wants to merge 12 commits into
PathGao wants to merge 12 commits into
Conversation
This was referenced Jul 13, 2026
PathGao
marked this pull request as ready for review
July 13, 2026 05:36
…e persistence Multi-window groundwork on the Rust side: - tab_transfer.rs: an in-memory transactional broker. A source window stages a serialized tab, a destination created as 'window-<token>' claims it (the token rides in the window label — the asset protocol 404s on URL queries), and the source is acknowledged via a targeted 'tab-transfer-claimed' event so it deletes its tab only after the hand-off is confirmed. Dirty content never touches disk or localStorage in transit. - create_transfer_window: sync command (window creation needs the main thread on macOS) using the same builder chrome as the main window. - File-open delivery picks the focused viewer window, else any viewer: the single-instance callback and macOS RunEvent::Opened previously hardcoded 'main' and silently dropped files once main was closed. Menu events now emit_to their window instead of broadcasting. - File watchers are keyed per window label (one shared slot meant any window toggling auto-reload killed every other window's watcher) and 'file-changed' targets the owning window only. - Window-state snapshots are written through save/load/clear_window_state commands: setItem is an async message to the WebKit storage process and loses its flush race when the last window's close exits the process; an awaited invoke holds the close open until fs::write returns. - Capabilities cover 'window-*' so secondary windows get the same permission set as main. - tauri-plugin-window-state maps 'window-*' labels to one shared 'secondary' entry; macOS secondaries opt into their shadow (main's shadow(false) is resurrected by the plugin's frame restore, a fresh secondary gets no restore and rendered shadowless). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TransferableTab deliberately differs from the persisted v2 snapshot: the transfer payload MUST carry rawContent/originalContent/isDirty (moving a dirty tab is the point) while the persistence snapshot must NOT (content's sole authority is the disk file; a snapshot that survives a power-off gap would resurrect stale content under a live tab and re-arm the stale-buffer + auto-save hazard). A content- carrying snapshot is safe here only because it spans ~a second inside one process with the source tab alive until acknowledged. validateTransferPayload is strict — every field type-checked, no coercion, no defaults: a tab whose content fields are not strings must never be constructed. Arrival titling keeps the tab's identity: an untitled tab is re-numbered only when the destination already has that exact title (impossible for a fresh detach window; ready for a future move-to-existing-window). Adds menu.moveToNewWindow to all 26 language tables and 16 node tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ab events The menu item is disabled for the HOME tab and single-tab windows (moving the only tab would just churn windows). Tab-strip context menus previously used global emit(), which broadcasts in Tauri 2 — 'New File' from a context menu would create a tab in EVERY window; they now emitTo their own window. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Only the main window persists and restores the window-state snapshot: localStorage (and the state file) is one shared slot per origin, so every window restoring it duplicated the whole tab set into detached windows, and the last window closed overwrote everyone else's state. Secondary labels are per-session, so their snapshots could never be restored anyway — main remembers, secondaries are ephemeral, matching browser session-restore semantics. - persistWindowState goes through the Rust write-through commands and drops the localStorage keys after the first write (read once for migration; a downgraded build starts fresh instead of misreading). - A window whose label carries a transfer token claims its tab from the broker on startup; invalid payloads are rejected outright rather than building an empty-shell tab. - handleDetach stages the snapshot, creates the window through Rust, and deletes the source tab only on claim acknowledgement; timeout or creation failure cancels and the tab stays. No canCloseTab() — moving preserves dirty state, movement is not closing. Guarded during a close-review walk. Detach previously passed only a file path in a URL, so dirty and untitled tabs could not move at all. - Every per-window listener is window-bound (listen() receives global broadcasts AND targeted events, so this composes with the emit_to migration in any order). - The reviewing window raises itself before its close-review dialogs: the walk's modals are in-app, and another window covering them made the close button look dead. - File-load failures surface as a toast (TCC permission denials died silently in the console, leaving an inexplicably empty tab). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The old snapshot's cleanup previously ran only at close and only with restore-on-reopen enabled, so users who disabled the setting kept the stale localStorage copy forever. Migration now completes at startup: restore, immediately persist through Rust (so a crash between steps cannot lose the snapshot), then drop both localStorage keys unconditionally. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Startup deletes the localStorage keys after migrating, so their presence means an older build wrote them since our last run — e.g. during a downgrade period. Reading the Rust file first would restore a stale pre-downgrade snapshot over the one the older build just wrote. localStorage-if-present is therefore always at least as fresh. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The hand-copied render/write-back/refresh trio at the claim site had already drifted (missing the _lastRenderedRawContent marker). One shared path means future pipeline changes reach the transfer flow for free. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
send_markdown_path serves process-global state (argv plus the macOS Opened-before-ready stash) and every window's init consumed it, so each detached window re-opened the file the app was launched with. Gate the consumer to the main window and make the stash a one-shot take(). Escaped QA because every test launch was a bare terminal start — launch mode (bare / argv file / Finder open) is a test dimension on par with window count. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When Finder delivers a file, Finder itself is frontmost, so is_focused() is false for every Markpad window and delivery degraded to arbitrary window-map order — users could not predict where a double-clicked file would open. Track the last-focused viewer label and use it as the middle rung: focused viewer, else last-focused still-alive viewer, else any. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PathGao
force-pushed
the
codex/native-multiwindow-tabs
branch
from
July 24, 2026 20:00
245e85d to
98a8563
Compare
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 join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Implements #74: multiple independent native Markpad windows. A tab moves to a new window via its context menu ("Move to New Window"), carrying its full state — unsaved content, edit/split mode, scroll, history — with no save prompt: moving is not closing.
Why menu-move instead of Chrome-style tab dragging
An earlier drag-based design was abandoned: nearly all of its cost served the drag gesture itself (cross-display coordinate math, window-bounds hit testing, cancel-anytime transactionality). An explicit menu action delivers the same user value (two documents side by side) at a fraction of the complexity, and the transfer broker introduced here is deliberately shaped so drag can be added on top later.
Architecture
Each Tauri window is an isolated WebView/JS context, so the existing singleton stores are naturally per-window — the frontend needed no store refactor at all. The real work was three shared resources that multiwindow exposes:
Moving a tab between JS contexts — an in-memory Rust broker. The source stages a serialized tab; the destination window is created with the transfer token embedded in its label (
window-<token>— a URL query would 404 in the asset protocol); the destination claims the payload and the source deletes its tab only after a targeted claim acknowledgement. Any failure (timeout, window-creation error) cancels and leaves the source tab untouched. Payloads are strictly validated — a tab whose content fields are not strings is never constructed (see Editor attributes the previous tab's content to a restored tab whose rawContent is missing (silent file corruption with auto-save) #213 for what stale/undefined buffers do).The shared window-state snapshot — only the
mainwindow persists and restores it. Secondary labels are per-session, so their snapshots could never be restored anyway; letting N windows share one write slot meant the last window closed overwrote everyone else, and every new window restored the full previous tab set. Persistence now goes through Rust (fs::writebehind an awaited invoke):setItemis an async message to the WebKit storage process and loses its flush race when the last window's close exits the process. The localStorage keys are read once for migration and then removed; a downgraded build starts a fresh session instead of misreading anything. The key/format is unchanged (savedTabsDataV2) — nothing about the stored bytes changed, so no new key.Authority principle: the disk file is the sole authority for document content. The persistence snapshot never carries content (it must survive a power-off gap during which the file may change elsewhere); the transfer payload may (it lives ~a second inside one process, with the source alive until acknowledged). Whether a content-carrying snapshot is safe depends on the time span it must survive, not where it is stored.
Broadcast events and global state — Tauri's
emit/window.emitbroadcast to every window, so e.g. "New File" from a tab context menu would create a tab in all windows. Per-window intents now useemit_to+ window-bound listeners. The file watcher was a single global slot (any window toggling auto-reload killed every other window's watcher) and is now keyed per window label. OS file-opens (single-instance argv and macOSRunEvent::Opened) previously hardcoded themainwindow — and silently dropped the file once main was closed; they now deliver to the focused viewer window, falling back to any viewer (Focus the existing window when opening Markdown files #193 fixed bringing the receiving window forward; this fixes choosing the receiver).Smaller pieces: untitled tabs keep their title on move unless the destination already uses it (the title is the document's identity; per-window smallest-free numbering is unchanged); the reviewing window raises itself before its close-review dialogs (they are in-app modals and could sit hidden behind another window); the capability ACL now covers
window-*labels;tauri-plugin-window-statemaps all secondary labels onto one state entry.Out of scope
Cross-window tab dragging, "move to existing window", restoring the multi-window layout across restarts, and the macOS double-copy file-open swallow (#212). Two pre-existing issues noticed during QA are filed separately rather than fixed here: context menus are not mutually exclusive, and the document context menu opens over modal dialogs.
Testing
svelte-checkclean. ThewindowStateRestoreguard test was updated for the Rust write-through (same invariant — v2 snapshots invisible to legacy builds — new mechanism).open -a, second-instance argv, and with main closed; per-window auto-reload watchers (toggling one window's off leaves the other's alive); two windows running their close reviews concurrently; event isolation (context-menu New File / Cmd+W / menu-bar Open all act on their own window only).🤖 Generated with Claude Code
Closes #125
Fixes #213.
Closes #74.