feat: window tags — named color chips, with pinned tags as reopenable sessions#226
Draft
PathGao wants to merge 29 commits into
Draft
feat: window tags — named color chips, with pinned tags as reopenable sessions#226PathGao wants to merge 29 commits into
PathGao wants to merge 29 commits into
Conversation
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>
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>
…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>
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>
Each viewer window's frontend pushes its display metadata (session- stable creation number, optional tag name/color, active tab title, tab count) into a Rust-side registry — tab state lives per WebView, so Rust cannot derive it. list_viewer_windows serves the 'Move to window …' menu and the ⌘⇧M cycle order; offer_tab_to_window routes a staged broker token to an existing window, which claims it through the same path a fresh detach window uses (ack-then-delete, timeout rollback, and the close-walk guard all inherited). Registry entries are pruned on window destruction. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…y shortcut
- Tab context menu lists every other window ('Move to <window> ·
<active tab>'); hovering an entry makes that window identify itself
with a brief accent ring and centered badge (no focus or z-order
change), so menu names never need guessing.
- Moving reuses the acknowledged broker path end to end: stage,
offer_tab_to_window, destination claims (rejected while its close
review is running), source deletes only on the claim ack, timeout
rolls back. transferTab() now resolves on the acknowledgement so
chained moves can await completion.
- 'Merge All Windows Here' (hamburger menu) asks every other window
to hand its tabs over tab-by-tab through the same path, dropping
recreatable HOME tabs; an emptied window closes itself.
- ⌘⇧M carries the active tab to the next window (creation order,
cyclic) with focus following — pressing repeatedly walks the tab
onward; with no other window it degrades to detach-to-new-window.
- New strings added to en/zh-CN/zh-TW/ja; other locales fall back to
en per the codebase's existing coverage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…everywhere
A Chrome-tab-group-style chip next to the hamburger button: click to
edit, eight fixed colors, name field (Enter applies, empty clears).
Hidden until set via the hamburger's 'Window Tag…' entry. Once set,
the tag becomes the window's identity everywhere windows need
telling apart: the 'Move to window …' menu labels, the identify
flash (ring and badge take the tag color), and the native window
title ('name — active tab') so Mission Control and the Window menu
show it too. Main's tag persists as an additive v2-snapshot field
that older builds ignore; secondary windows' tags are session-local
like the windows themselves.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Right-clicking the tag chip now shows the tag's own menu (previously the document context menu leaked through): Pin / Unpin, edit, clear. A pinned tag is a named session — the window saves its open file paths under the tag whenever it closes (per-tab close walk, last-tab close, and app exit paths all hook in, awaited so the write cannot race process exit), and the HOME page lists pinned tags as colored cards that reopen the whole set in the current window with the tag applied. Unpinning removes the saved set; the chip shows a pin glyph while pinned; clearing a pinned tag also unpins it. Also from live QA feedback: 'Move to window …' labels drop the active-tab suffix (the hover identify-flash is the disambiguator, the filename was noise), and the flash ring sits 6px inside the window edge with a larger radius so it stays concentric with the macOS window corners. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… wording
- Editing a tag's name or color no longer drops its pinned state —
losing it orphaned the saved session on the HOME page, whose card
then re-applied pinned on the next click ('tags pin themselves').
Renaming a pinned tag renames its saved session; clearing a pinned
tag unpins it. Pinning still happens only via the explicit chip
menu action (or opening an already-pinned card).
- The chip context menu's editor entry now reads 'Rename Tag…'
instead of the ambiguous 'Window Tag…' (the hamburger keeps the
latter as the set-a-tag entry point).
- All window-organizing menu strings now cover all 26 locales; the
toast/home strings match their sections' existing coverage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ival An unpinned tag rode main's v2 snapshot and reappeared after restart, which read as 'tags pin themselves' — two persistence channels (snapshot identity vs pinned session) overlapped. Rule per design discussion: unpinned tags are session decoration and die with the window; a pinned tag alone survives restarts, governing name, color, saved file set, and the HOME card with one switch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.
Optional window tags: a Chrome-tab-group-style colored name chip next to the hamburger button (hidden until set via the hamburger's "Window Tag…"; click to edit, eight fixed colors). Once set, the tag is the window's identity everywhere windows need telling apart: "Move to " labels, the identify flash (ring and badge take the tag color), and the native window title ("name — active tab") so Mission Control shows it too.
Pinned tags are named sessions. Right-clicking the chip offers Pin/Unpin (and rename/clear — previously the document context menu leaked through here). A pinned window saves its open file paths under the tag at every close (per-tab review, last-tab close, and app-exit paths all hook in, awaited so the write cannot race process exit). The HOME page lists pinned tags as colored cards that reopen the whole set with the tag applied.
Pinning is the single gateway to persistence: an unpinned tag is session decoration and dies with its window; a pinned tag alone survives restarts — one switch governs name, color, saved file set, HOME card, and restart survival. (Editing a pinned tag preserves the pinned state and renames its saved session.) The tag rides the v2 snapshot as an additive field older builds ignore.
New strings cover all 26 locales.
🤖 Generated with Claude Code