Skip to content

feat: independent native windows with menu-based tab transfer#214

Open
PathGao wants to merge 20 commits into
alecdotdev:masterfrom
PathGao:codex/native-multiwindow-tabs
Open

feat: independent native windows with menu-based tab transfer#214
PathGao wants to merge 20 commits into
alecdotdev:masterfrom
PathGao:codex/native-multiwindow-tabs

Conversation

@PathGao

@PathGao PathGao commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Stacked on #211 (and sharing #210's baseline) — please review only the last 4 commits. This branch is based on the two in-flight PRs; the multiwindow work semantically depends on #211's window-state snapshot, per-tab close review, and untitled numbering. I will rebase onto master as soon as they land, which will reduce the diff to the 4 commits listed below.

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:

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

  2. The shared window-state snapshot — only the main window 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::write behind an awaited invoke): setItem is 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.

  3. Broadcast events and global state — Tauri's emit/window.emit broadcast to every window, so e.g. "New File" from a tab context menu would create a tab in all windows. Per-window intents now use emit_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 macOS RunEvent::Opened) previously hardcoded the main window — 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-state maps 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

  • Automated: 16 new node tests for the transfer snapshot (round-trip fidelity incl. unsaved content, strict validation rejections, arrival titling), 4 Rust broker unit tests (stage/claim/cancel/eviction), all existing suites green, svelte-check clean. The windowStateRestore guard test was updated for the Rust write-through (same invariant — v2 snapshots invisible to legacy builds — new mechanism).
  • Manual (macOS): dirty and untitled tab moves; both window-close orders restore main's tabs; file-open routing via 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).
  • Environment note: utilities that force-quit "windowless" apps (e.g. Supercharge) terminate via a Cocoa-level Quit that bypasses every window-close path; persistence semantics are "state as of the last graceful close". Ad-hoc-signed dev bundles are invisible to such utilities' accessibility checks and get culled — excluded during QA.

🤖 Generated with Claude Code

Closes #125

PathGao and others added 15 commits July 13, 2026 00:25
Replace the CSS grid 1fr/0fr fold animation with an explicit measured
height. WebView failed to recompute the 1fr grid track after content
reflow on resize or cross-display moves, leaving stale gaps or overlap.

observeFoldLayout() watches each expanded .content-inner with a
ResizeObserver, batches writes through one animation frame, and publishes
the measured scrollHeight as --fold-content-height (innermost wrappers
first so nested measurements stay consistent). The wrapper animates that
explicit height, so expanded sections track their content and collapsed
sections stay at height 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The height transition is meant only for fold/unfold. Because a resize
also changes the measured height, the wrapper animated toward the new
height over 0.25s while its content had already reflowed, leaving the
next section overlapping the still-visible overflow on every resize.

Suppress the transition around each measured write and force one layout
to commit it, then restore the stylesheet transition so fold toggles
still animate. Verified in a standalone harness: on resize the wrapper
now matches its content instantly (97->97, 173->173 across widths) while
the fold-toggle duration stays 0.25s.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the aggregate "you have N unsaved files" modal with a per-tab
walk (issue alecdotdev#189): activate each dirty tab and run the same localized
unsaved-changes dialog a single tab close shows (canCloseTab), then
close the tab. Cancel stops the walk and keeps the window open with the
remaining tabs; the window closes only after every dirty tab is
resolved. The auto-save fast path and the restore-on-reopen branch are
untouched original behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The restore snapshot previously carried every tab's full document
content (rawContent + originalContent + history), doubling as an
unreliable unsaved-content store and letting restored tabs show stale
bytes when the file changed on disk while the app was closed.

Separate the concerns. serializeState (v2) records window state only:
open file paths, active tab, edit mode, split, scroll. On startup each
restored tab reads its file from disk; unreadable files drop their tab.
The close flow resolves dirty tabs FIRST through the per-tab dialogs —
regardless of the restore setting — then writes the snapshot: Save
persists to disk, Don't Save reverts the tab to its last saved content,
Cancel keeps the window open. Untitled tabs are never persisted; the
auto-save fast path still saves titled tabs silently. Legacy snapshots
restore through the same path (window-state fields only).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The red button is a native control, so the in-app dialog overlay does
not block it: a second click while the walk had a dialog up re-entered
the handler and started a competing walk whose setActive calls fought
the first one — with two untitled tabs the highlight and the dialog
visibly disagreed.

Guard the walk with a re-entrancy flag (released in finally, including
the cancel path), and start each round from the tab the user is already
looking at so the highlight only jumps when it has to.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A legacy build restoring a v2 window-state snapshot reconstructs tabs
whose rawContent is undefined (it expects full-tab snapshots). Its
editor then fails to swap buffers on tab switch and attributes the
previous tab's still-visible content to the newly active tab — and
auto-save writes that misattributed content to disk. Observed live:
after a v2-format close, an older build showed 4.md's content under the
3.md tab; one edit event away from corrupting 3.md on disk.

Write v2 snapshots under their own key (savedTabsDataV2) and remove the
legacy key on every write, so an older build sharing the same storage
container never sees a format it cannot restore. Startup reads the v2
key first and falls back to the legacy key once for migration; explicit
exit clears both.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
With two untitled tabs both called "Untitled", the per-tab
unsaved-changes dialog at window close could not tell the user which
tab it was asking about. Give untitled tabs numbered titles
("Untitled 1", "Untitled 2", reusing the smallest free number), so the
tab strip and every dialog naming a tab become unambiguous. A legacy
unnumbered title counts as slot 1; localized bases work unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The close walk preferred the active tab, then wrapped around the strip —
starting on the third of three tabs produced the sequence 3, 1, 2, which
reads as random. Walk strictly left to right instead: numbered untitled
titles already keep the dialog unambiguous, so predictability wins over
avoiding one highlight jump.

Also prefill the untitled Save As dialog with the numbered tab title so
the save panel itself says which tab is being saved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…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>
PathGao and others added 2 commits July 13, 2026 10:09
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>
@PathGao PathGao marked this pull request as ready for review July 13, 2026 05:36
PathGao and others added 3 commits July 13, 2026 14:39
…dow-close

# Conflicts:
#	src/lib/MarkdownViewer.svelte
Carries upstream/master via the updated 211 branch. Transferred-tab
rendering now goes through renderMarkdownPreview so cross-window moves
get the same frontmatter handling as every other preview path.

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add ability to detach a tab into a new window

1 participant