From 8390e420b6eb502beffb0bf76a730ccfe8fc947f Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Mon, 13 Jul 2026 00:25:40 +0900 Subject: [PATCH 01/19] test: reproduce fold layout resize regression --- samples/fold-resize-regression.md | 32 +++++++++++++++++++++++++++++++ scripts/foldLayout.test.ts | 27 ++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 samples/fold-resize-regression.md create mode 100644 scripts/foldLayout.test.ts diff --git a/samples/fold-resize-regression.md b/samples/fold-resize-regression.md new file mode 100644 index 0000000..7d59fcc --- /dev/null +++ b/samples/fold-resize-regression.md @@ -0,0 +1,32 @@ +# Fold resize regression + +## Overview + +This short section must remain directly above the next heading after the window moves between displays or changes width. + +> Wrapped blockquote text changes height when the preview width changes. It must remain in normal document flow without covering the next heading. + +```text +Line one +Line two +Line three +``` + +## Workflow + +1. Resize the preview from wide to narrow. +2. Move the window between displays with different scaling. +3. Fold and unfold this section. + +### Nested section + +This nested content exercises the inner fold wrapper. + +| Column A | Column B | +| --- | --- | +| A long value that wraps at narrow widths | Another value | + +## Final section + +This heading must never overlap Workflow or be separated by a viewport-sized gap. + diff --git a/scripts/foldLayout.test.ts b/scripts/foldLayout.test.ts new file mode 100644 index 0000000..3ec76c7 --- /dev/null +++ b/scripts/foldLayout.test.ts @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +test('fold layout observes rendered content and publishes its measured height', () => { + const source = readFileSync('src/lib/utils/foldLayout.ts', 'utf8'); + + assert.match(source, /new ResizeObserver/); + assert.match(source, /--fold-content-height/); + assert.match(source, /requestAnimationFrame/); +}); + +test('fold wrapper animates an explicit measured height instead of a fractional grid track', () => { + const styles = readFileSync('src/styles.css', 'utf8'); + const expandedRule = styles.match(/\.foldable-content-wrapper\s*\{([^}]*)\}/)?.[1] || ''; + + assert.match(expandedRule, /height:\s*var\(--fold-content-height/); + assert.doesNotMatch(expandedRule, /grid-template-rows:\s*1fr/); + assert.match(styles, /foldable-content-wrapper\.is-collapsed[\s\S]*height:\s*0/); +}); + +test('preview lifecycle starts and cleans up fold observation', () => { + const viewer = readFileSync('src/lib/MarkdownViewer.svelte', 'utf8'); + + assert.match(viewer, /observeFoldLayout\(markdownBody\)/); + assert.match(viewer, /stopObservingFoldLayout\?\.\(\)/); +}); From e5ea3d9bfbeb9f3052b09d19bec2e3251c441b9a Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Mon, 13 Jul 2026 00:25:40 +0900 Subject: [PATCH 02/19] fix: keep folded sections aligned after resize 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 --- scripts/foldLayout.test.ts | 2 +- src/lib/MarkdownViewer.svelte | 21 ++++++++++++++++++ src/lib/utils/foldLayout.ts | 41 +++++++++++++++++++++++++++++++++++ src/styles.css | 20 ++++++++++++++--- 4 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 src/lib/utils/foldLayout.ts diff --git a/scripts/foldLayout.test.ts b/scripts/foldLayout.test.ts index 3ec76c7..2bad3d3 100644 --- a/scripts/foldLayout.test.ts +++ b/scripts/foldLayout.test.ts @@ -22,6 +22,6 @@ test('fold wrapper animates an explicit measured height instead of a fractional test('preview lifecycle starts and cleans up fold observation', () => { const viewer = readFileSync('src/lib/MarkdownViewer.svelte', 'utf8'); - assert.match(viewer, /observeFoldLayout\(markdownBody\)/); + assert.match(viewer, /observeFoldLayout\((?:markdownBody|body)\)/); assert.match(viewer, /stopObservingFoldLayout\?\.\(\)/); }); diff --git a/src/lib/MarkdownViewer.svelte b/src/lib/MarkdownViewer.svelte index 4e357e8..0c55186 100644 --- a/src/lib/MarkdownViewer.svelte +++ b/src/lib/MarkdownViewer.svelte @@ -24,6 +24,7 @@ import { askToOpenExportedFile } from './utils/openExportedFile.js'; import ZoomOverlay from './components/ZoomOverlay.svelte'; import { processMarkdownHtml } from './utils/markdown'; +import { observeFoldLayout } from './utils/foldLayout.js'; import { decodeLinkPath, getMarkdownLinkTarget as getRelativeMarkdownTarget, @@ -67,6 +68,7 @@ import { t } from './utils/i18n.js'; let markdownBody: HTMLElement | null = $state(null); const renderDebounceMs = 50; let renderTimeout: ReturnType | null = null; + let stopObservingFoldLayout: (() => void) | null = null; const highlightColorMap: Record = { default: 'color-mix(in srgb, var(--color-accent-fg) 40%, transparent)', @@ -755,6 +757,25 @@ import { t } from './utils/i18n.js'; if (sanitizedHtml && markdownBody && !isEditing && hljs && renderMathInElement && mermaid) renderRichContent(); }); + $effect(() => { + const html = sanitizedHtml; + const body = markdownBody; + if (!html || !body) return; + + let cancelled = false; + tick().then(() => { + if (cancelled || body !== markdownBody) return; + stopObservingFoldLayout?.(); + stopObservingFoldLayout = observeFoldLayout(body); + }); + + return () => { + cancelled = true; + stopObservingFoldLayout?.(); + stopObservingFoldLayout = null; + }; + }); + // Re-apply find highlights after the preview HTML is replaced. The // `bind:innerHTML={sanitizedHtml}` on the article wipes the DOM on every // edit/render pass; without this, highlights vanish until the user diff --git a/src/lib/utils/foldLayout.ts b/src/lib/utils/foldLayout.ts new file mode 100644 index 0000000..eaf7c26 --- /dev/null +++ b/src/lib/utils/foldLayout.ts @@ -0,0 +1,41 @@ +const FOLD_WRAPPER_SELECTOR = '.foldable-content-wrapper'; +const FOLD_CONTENT_SELECTOR = ':scope > .content-inner'; + +function updateFoldHeights(root: HTMLElement) { + const wrappers = Array.from(root.querySelectorAll(FOLD_WRAPPER_SELECTOR)); + + for (const wrapper of wrappers.reverse()) { + const content = wrapper.querySelector(FOLD_CONTENT_SELECTOR); + if (!content) continue; + + wrapper.style.setProperty('--fold-content-height', `${Math.ceil(content.scrollHeight)}px`); + } +} + +export function observeFoldLayout(root: HTMLElement): () => void { + let frame: number | null = null; + + const scheduleUpdate = () => { + if (frame !== null) return; + frame = requestAnimationFrame(() => { + frame = null; + updateFoldHeights(root); + }); + }; + + const observer = new ResizeObserver(scheduleUpdate); + for (const content of root.querySelectorAll( + `${FOLD_WRAPPER_SELECTOR} > .content-inner`, + )) { + observer.observe(content); + } + + window.addEventListener('resize', scheduleUpdate); + scheduleUpdate(); + + return () => { + observer.disconnect(); + window.removeEventListener('resize', scheduleUpdate); + if (frame !== null) cancelAnimationFrame(frame); + }; +} diff --git a/src/styles.css b/src/styles.css index ca405e1..7d43a36 100644 --- a/src/styles.css +++ b/src/styles.css @@ -548,14 +548,28 @@ body { display: none !important; } -.foldable-content-wrapper, .markdown-alert-content { +.foldable-content-wrapper { + height: var(--fold-content-height, auto); + transition: height 0.25s ease, opacity 0.25s ease; + opacity: 1; + overflow: visible; /* show carets in the gutter when expanded */ +} + +.foldable-content-wrapper.is-collapsed { + height: 0; + opacity: 0; + overflow: hidden; /* must hide when collapsed */ +} + +.markdown-alert-content { display: grid; grid-template-rows: 1fr; transition: grid-template-rows 0.25s ease, opacity 0.25s ease; opacity: 1; - overflow: visible; /* show carets in the gutter when expanded */ + overflow: visible; } -.foldable-content-wrapper.is-collapsed, .markdown-alert-content.is-collapsed { + +.markdown-alert-content.is-collapsed { grid-template-rows: 0fr; opacity: 0; overflow: hidden; /* must hide when collapsed */ From 4e07675716240542099f0c2da98882044466a7cb Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Mon, 13 Jul 2026 00:25:40 +0900 Subject: [PATCH 03/19] fix: apply reflow-driven fold heights without animating 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 --- src/lib/utils/foldLayout.ts | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/src/lib/utils/foldLayout.ts b/src/lib/utils/foldLayout.ts index eaf7c26..f6d7bed 100644 --- a/src/lib/utils/foldLayout.ts +++ b/src/lib/utils/foldLayout.ts @@ -2,13 +2,35 @@ const FOLD_WRAPPER_SELECTOR = '.foldable-content-wrapper'; const FOLD_CONTENT_SELECTOR = ':scope > .content-inner'; function updateFoldHeights(root: HTMLElement) { - const wrappers = Array.from(root.querySelectorAll(FOLD_WRAPPER_SELECTOR)); - - for (const wrapper of wrappers.reverse()) { + // Innermost wrappers first so a parent measures its child's freshly + // written height rather than a stale one. + const wrappers = Array.from( + root.querySelectorAll(FOLD_WRAPPER_SELECTOR), + ).reverse(); + + // Reflow-driven height writes must land instantly. The `height` transition + // exists only to animate fold/unfold; letting it also animate every resize + // leaves the expanded wrapper shorter than its content for ~0.25s, so the + // following section overlaps the still-visible overflow. Suppress the + // transition around the write, force one layout to commit it, then restore + // the stylesheet transition for the next fold toggle. + const written: HTMLElement[] = []; + for (const wrapper of wrappers) { const content = wrapper.querySelector(FOLD_CONTENT_SELECTOR); if (!content) continue; + wrapper.style.transition = 'none'; wrapper.style.setProperty('--fold-content-height', `${Math.ceil(content.scrollHeight)}px`); + written.push(wrapper); + } + + if (written.length === 0) return; + + // Single forced reflow commits every suppressed height write at once. + void root.offsetHeight; + + for (const wrapper of written) { + wrapper.style.transition = ''; } } From 9205933a244b785634bbca64094a30389cf8e76a Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Mon, 13 Jul 2026 00:25:58 +0900 Subject: [PATCH 04/19] fix: close dirty tabs one at a time on window close Replace the aggregate "you have N unsaved files" modal with a per-tab walk (issue #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 --- scripts/windowClosePerTab.test.ts | 62 +++++++++++++++++++++++++++++++ src/lib/MarkdownViewer.svelte | 45 +++++++++------------- 2 files changed, 79 insertions(+), 28 deletions(-) create mode 100644 scripts/windowClosePerTab.test.ts diff --git a/scripts/windowClosePerTab.test.ts b/scripts/windowClosePerTab.test.ts new file mode 100644 index 0000000..01f134e --- /dev/null +++ b/scripts/windowClosePerTab.test.ts @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +const viewer = readFileSync('src/lib/MarkdownViewer.svelte', 'utf8'); + +// Window close (issue #189): instead of one aggregate "you have N unsaved +// files" modal, the red close button walks the dirty tabs one at a time — +// activating each and showing the SAME localized unsaved-changes dialog a +// single tab close shows (canCloseTab). Cancel stops the walk; the window +// stays open with the remaining tabs. + +function closeHandler(): string { + const start = viewer.indexOf('appWindow.onCloseRequested'); + assert.ok(start !== -1, 'close handler registration not found'); + return viewer.slice(start, viewer.indexOf('onDragDropEvent', start)); +} + +test('the aggregate unsaved-files modal is gone from the close handler', () => { + const handler = closeHandler(); + assert.doesNotMatch(handler, /youHaveUnsavedFiles/); + // and the old "clear all dirty flags then close" discard path with it + assert.doesNotMatch(handler, /tabManager\.tabs\.forEach\(\(t\) => \(t\.isDirty = false\)\)/); +}); + +test('dirty tabs are reviewed one at a time through the existing canCloseTab flow', () => { + const handler = closeHandler(); + // activate the tab under review so the user sees what the dialog is about + assert.match(handler, /tabManager\.setActive\(dirty\.id\);/); + // the existing localized per-tab dialog decides save / discard / cancel + assert.match(handler, /await canCloseTab\(dirty\.id\)/); + // a resolved tab actually closes before moving on + assert.match(handler, /tabManager\.closeTab\(dirty\.id\);/); +}); + +test('cancelling the per-tab dialog stops the walk and keeps the window open', () => { + const handler = closeHandler(); + assert.match(handler, /if \(!\(await canCloseTab\(dirty\.id\)\)\) return;/); +}); + +test('the close is prevented synchronously before the per-tab walk', () => { + const handler = closeHandler(); + const branchStart = handler.indexOf('if (dirtyTabs.length > 0) {'); + assert.ok(branchStart !== -1, 'dirty branch not found'); + const prevent = handler.indexOf('event.preventDefault()', branchStart); + const walk = handler.indexOf('canCloseTab(dirty.id)', branchStart); + assert.ok(prevent !== -1 && walk !== -1 && prevent < walk); +}); + +test('the window closes only after every dirty tab is resolved', () => { + const handler = closeHandler(); + const walk = handler.indexOf('canCloseTab(dirty.id)'); + const close = handler.indexOf('appWindow.close()', walk); + assert.ok(walk !== -1 && close !== -1 && walk < close); +}); + +test('the restore-on-reopen branch is untouched original behavior', () => { + const handler = closeHandler(); + assert.match(handler, /localStorage\.setItem\('savedTabsData', stateStr\);/); + // no durable-write experiment left behind + assert.doesNotMatch(viewer, /saveSessionState|sessionState\.js/); +}); diff --git a/src/lib/MarkdownViewer.svelte b/src/lib/MarkdownViewer.svelte index 4e357e8..1b156a4 100644 --- a/src/lib/MarkdownViewer.svelte +++ b/src/lib/MarkdownViewer.svelte @@ -2579,34 +2579,23 @@ import { t } from './utils/i18n.js'; // hasUntitled: skip toast, just fall through to the modal. } - const response = await askCustom(t('modal.youHaveUnsavedFiles', settings.language).replace('{{count}}', dirtyTabs.length.toString()), { - title: t('modal.unsavedChanges', settings.language), - kind: 'warning', - showSave: true, - }); - - if (response === 'save') { - // Attempt to save all dirty tabs - for (const tab of dirtyTabs) { - tabManager.setActive(tab.id); - await tick(); - cancelPendingAutoSave(tab.id); - const saved = await saveContent(tab.id); - if (!saved) return; // Cancelled or failed - } - // If all saved successfully, close the app - appWindow.close(); - } else if (response === 'discard') { - // Force close by removing this listener or skipping check? - // Since we are inside the event handler, we can't easily remove "this" listener specifically - // without refactoring how unlisteners are stored/accessed relative to this callback. - // However, if we just want to exit, we can use exit() from rust or just appWindow.destroy()? - // WebviewWindow.close() triggers this event again. - // Solution: invoke a command to exit forcefully or set a flag. - // The simplest might be to just clear the dirty flags and close. - tabManager.tabs.forEach((t) => (t.isDirty = false)); - appWindow.close(); - } + // Close the dirty tabs one at a time (issue #189): activate + // each tab and run the same localized unsaved-changes dialog + // a single tab close shows. Cancel stops the walk and keeps + // the window open with the remaining tabs. Re-find the next + // dirty tab every round — a save can leave a tab dirty again + // (TOCTOU) and tabs can change while a dialog is up. + while (true) { + const dirty = tabManager.tabs.find((t) => t.isDirty); + if (!dirty) break; + tabManager.setActive(dirty.id); + await tick(); + if (!(await canCloseTab(dirty.id))) return; + tabManager.closeTab(dirty.id); + } + // Every dirty tab is resolved; this close re-enters the + // handler, finds nothing dirty, and proceeds. + appWindow.close(); } }), ); From 29dec4bcde51fbc97812ad77a1aea0c908502690 Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Mon, 13 Jul 2026 00:25:58 +0900 Subject: [PATCH 05/19] feat: restore window state only; content always comes from disk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- scripts/windowStateRestore.test.ts | 81 +++++++++++++++ src/lib/MarkdownViewer.svelte | 154 ++++++++++++++--------------- src/lib/stores/tabs.svelte.ts | 64 +++++++++++- 3 files changed, 214 insertions(+), 85 deletions(-) create mode 100644 scripts/windowStateRestore.test.ts diff --git a/scripts/windowStateRestore.test.ts b/scripts/windowStateRestore.test.ts new file mode 100644 index 0000000..2e3cabe --- /dev/null +++ b/scripts/windowStateRestore.test.ts @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +const tabs = readFileSync('src/lib/stores/tabs.svelte.ts', 'utf8'); +const viewer = readFileSync('src/lib/MarkdownViewer.svelte', 'utf8'); + +// Session restore persists WINDOW state only: which files are open, the +// active tab, and per-tab UI (edit mode, split, scroll). Document content +// always lives on disk — the snapshot never carries rawContent, so unsaved +// changes are handled exclusively by the per-tab close dialogs. + +function slice(source: string, from: string, to: string): string { + const start = source.indexOf(from); + assert.ok(start !== -1, `${from} not found`); + const end = source.indexOf(to, start); + assert.ok(end !== -1, `${to} not found after ${from}`); + return source.slice(start, end); +} + +test('serializeState writes window state only', () => { + const fn = slice(tabs, 'serializeState()', 'restoreState('); + assert.match(fn, /version: 2/); + // untitled tabs have no disk backing; they are resolved at close, never persisted + assert.match(fn, /filter\(\(t\) => t\.path !== ''\)/); + // no full-object spread and no content fields in the snapshot + assert.doesNotMatch(fn, /\.\.\.t/); + assert.doesNotMatch(fn, /rawContent/); + assert.doesNotMatch(fn, /originalContent/); + assert.doesNotMatch(fn, /isDirty/); + assert.doesNotMatch(fn, /history/); +}); + +test('restoreState rebuilds clean tabs and drops legacy untitled entries', () => { + const fn = slice(tabs, 'restoreState(', 'addTab('); + // path is the identity of a restored tab; entries without one are skipped + assert.match(fn, /saved\.path === ''\) continue;/); + // restored tabs start clean; content is read from disk afterwards + assert.match(fn, /isDirty: false/); + assert.match(fn, /rawContent: ''/); + // a stale activeTabId falls back to the first restored tab + assert.match(fn, /activeTabId/); +}); + +test('startup restore reads content from disk, not from the snapshot', () => { + const init = slice(viewer, "localStorage.getItem('savedTabsData')", 'urlParams'); + assert.match(init, /read_file_content/); + // a missing file drops its tab instead of restoring a ghost + assert.match(init, /closeTab\(/); +}); + +test('the discard choice reverts the tab to its last saved content', () => { + const fn = slice(viewer, 'async function canCloseTab', 'async function toggleEdit'); + assert.match(fn, /tab\.rawContent = tab\.originalContent;/); + assert.match(fn, /tab\.isDirty = false;/); +}); + +test('the close flow resolves dirty tabs before serializing window state', () => { + const handlerStart = viewer.indexOf('appWindow.onCloseRequested'); + const handler = viewer.slice(handlerStart, viewer.indexOf('onDragDropEvent', handlerStart)); + const walk = handler.indexOf('canCloseTab(dirty.id)'); + const persist = handler.indexOf("localStorage.setItem('savedTabsData'"); + assert.ok(walk !== -1, 'per-tab walk not found'); + assert.ok(persist !== -1, 'window-state serialization not found'); + assert.ok(walk < persist, 'dirty tabs must be resolved before the snapshot is written'); +}); + +test('with restore enabled resolved titled tabs stay open for the snapshot', () => { + const handlerStart = viewer.indexOf('appWindow.onCloseRequested'); + const handler = viewer.slice(handlerStart, viewer.indexOf('onDragDropEvent', handlerStart)); + // tabs are closed one-by-one only when restore is off (or untitled) + assert.match(handler, /restoreStateOnReopen \|\| dirty\.path === ''/); +}); + +test('auto-save fast path silently saves titled tabs before the walk', () => { + const handlerStart = viewer.indexOf('appWindow.onCloseRequested'); + const handler = viewer.slice(handlerStart, viewer.indexOf('onDragDropEvent', handlerStart)); + const fastPath = handler.indexOf('settings.autoSave && !settings.confirmBeforeSave'); + const walk = handler.indexOf('canCloseTab(dirty.id)'); + assert.ok(fastPath !== -1 && walk !== -1 && fastPath < walk); +}); diff --git a/src/lib/MarkdownViewer.svelte b/src/lib/MarkdownViewer.svelte index 1b156a4..069182c 100644 --- a/src/lib/MarkdownViewer.svelte +++ b/src/lib/MarkdownViewer.svelte @@ -1301,9 +1301,12 @@ import { t } from './utils/i18n.js'; } // Discard: drop pending save so we don't write what the user just - // threw away. The effect will re-arm a timer if the tab gets edited - // again. + // threw away, and revert to the last saved content so the tab is + // clean — callers either close it (tab close) or keep it open for the + // window-state snapshot (window close with restore enabled). cancelPendingAutoSave(tabId); + tab.rawContent = tab.originalContent; + tab.isDirty = false; return true; } @@ -2369,17 +2372,23 @@ import { t } from './utils/i18n.js'; const savedData = localStorage.getItem('savedTabsData'); if (savedData) { tabManager.restoreState(savedData); - for (const tab of tabManager.tabs) { - if (!tab.content && tab.rawContent) { - invoke('render_markdown', { content: tab.rawContent }) - .then((html) => { - const processed = processMarkdownHtml(html as string, tab.path, collapsedHeaders); - tabManager.updateTabContent(tab.id, processed); - if (tabManager.activeTabId === tab.id) { - tick().then(renderRichContent); - } - }) - .catch(console.error); + // The snapshot carries window state only — content always + // comes from disk, so restored tabs show the file's real + // current bytes. A file that no longer exists drops its tab. + for (const tab of [...tabManager.tabs]) { + try { + const raw = (await invoke('read_file_content', { path: tab.path })) as string; + tab.rawContent = raw; + tab.originalContent = raw; + const html = await invoke('render_markdown', { content: raw }); + const processed = processMarkdownHtml(html as string, tab.path, collapsedHeaders); + tabManager.updateTabContent(tab.id, processed); + if (tabManager.activeTabId === tab.id) { + tick().then(renderRichContent); + } + } catch (e) { + console.warn('Restore: dropping tab for unreadable file', tab.path, e); + tabManager.closeTab(tab.id); } } } @@ -2516,26 +2525,57 @@ import { t } from './utils/i18n.js'; console.log('onCloseRequested triggered'); if (isForceExiting) return; - // CRITICAL: before serializing tab state to localStorage - // (the restore-on-reopen path), make sure all pending - // auto-save edits are actually flushed to disk. Without - // this, closing the window with auto-save on but - // confirmBeforeSave off would silently put unsaved edits - // in localStorage only and never persist them to file. - if (settings.autoSave && !settings.confirmBeforeSave) { - const dirtyWithPath = tabManager.tabs.filter( - (t) => t.isDirty && t.path !== '', - ); - for (const tab of dirtyWithPath) { - cancelPendingAutoSave(tab.id); - try { - await saveContent(tab.id); - } catch (e) { - console.error('Flush-on-close save failed for tab', tab.id, e); + // Unsaved content and session restore are separate concerns: + // dirty tabs are resolved FIRST through the per-tab dialogs, + // then the restore snapshot records window state only (open + // files, active tab, edit mode, split, scroll) — it never + // carries document content. + const dirtyTabs = tabManager.tabs.filter((t) => t.isDirty); + if (dirtyTabs.length > 0) { + event.preventDefault(); + + // Auto-save without confirmation: silently save every + // dirty tab that has a real path. Untitled tabs need a + // Save dialog, so the walk below handles them. A failed + // silent save is surfaced and its tab also goes to the + // walk. Timers are cancelled per tab right before its + // save to avoid duplicate writes. + if (settings.autoSave && !settings.confirmBeforeSave) { + for (const tab of dirtyTabs.filter((t) => t.path !== '')) { + cancelPendingAutoSave(tab.id); + const ok = await saveContent(tab.id); + if (!ok) { + addToast(t('toast.autoSaveFailed', settings.language), 'error'); + break; + } + } + } + + // Close review (issue #189): walk the remaining dirty + // tabs one at a time — activate each and run the same + // localized unsaved-changes dialog a single tab close + // shows. Cancel stops the walk and keeps the window + // open. Re-find the next dirty tab every round — a save + // can leave a tab dirty again (TOCTOU) and tabs can + // change while a dialog is up. + while (true) { + const dirty = tabManager.tabs.find((t) => t.isDirty); + if (!dirty) break; + tabManager.setActive(dirty.id); + await tick(); + if (!(await canCloseTab(dirty.id))) return; + // Resolved tabs (saved, or reverted by Don't Save) + // stay open for the window-state snapshot when + // restore is enabled; untitled tabs have nothing to + // restore, and with restore off the red button + // closes tabs one by one. + if (!settings.restoreStateOnReopen || dirty.path === '') { + tabManager.closeTab(dirty.id); } } } + // Session is clean now; record the window state for restore. if (settings.restoreStateOnReopen) { try { const stateStr = tabManager.serializeState(); @@ -2543,60 +2583,12 @@ import { t } from './utils/i18n.js'; } catch (e) { console.error('Failed to save state on close:', e); } - return; - } - - const dirtyTabs = tabManager.tabs.filter((t) => t.isDirty); - console.log('Dirty tabs:', dirtyTabs.length); - if (dirtyTabs.length > 0) { - console.log('Preventing default close'); - event.preventDefault(); - - // Auto-save without confirmation: try silently saving every dirty - // tab that has a real path. If untitled tabs exist they need a - // Save dialog, so we just fall through to the modal — that is - // NOT a failure case and shouldn't show an error toast. We - // also DON'T clear pending timers up front: if the user picks - // Cancel in the modal below, we want the timers to keep - // running for tabs that are still dirty. - if (settings.autoSave && !settings.confirmBeforeSave) { - const tabsWithPath = dirtyTabs.filter((t) => t.path !== ''); - const hasUntitled = dirtyTabs.some((t) => t.path === ''); - if (!hasUntitled) { - let allOk = true; - for (const tab of tabsWithPath) { - cancelPendingAutoSave(tab.id); - const ok = await saveContent(tab.id); - if (!ok) { allOk = false; break; } - } - if (allOk) { - appWindow.close(); - return; - } - // A real save failure happened — surface it. - addToast(t('toast.autoSaveFailed', settings.language), 'error'); } - // hasUntitled: skip toast, just fall through to the modal. - } - // Close the dirty tabs one at a time (issue #189): activate - // each tab and run the same localized unsaved-changes dialog - // a single tab close shows. Cancel stops the walk and keeps - // the window open with the remaining tabs. Re-find the next - // dirty tab every round — a save can leave a tab dirty again - // (TOCTOU) and tabs can change while a dialog is up. - while (true) { - const dirty = tabManager.tabs.find((t) => t.isDirty); - if (!dirty) break; - tabManager.setActive(dirty.id); - await tick(); - if (!(await canCloseTab(dirty.id))) return; - tabManager.closeTab(dirty.id); - } - // Every dirty tab is resolved; this close re-enters the - // handler, finds nothing dirty, and proceeds. - appWindow.close(); - } + // If we intercepted the close to run the review, re-trigger + // it: the handler re-enters, finds nothing dirty, and the + // close proceeds. + if (dirtyTabs.length > 0) appWindow.close(); }), ); diff --git a/src/lib/stores/tabs.svelte.ts b/src/lib/stores/tabs.svelte.ts index 73506d4..ec8226c 100644 --- a/src/lib/stores/tabs.svelte.ts +++ b/src/lib/stores/tabs.svelte.ts @@ -54,21 +54,77 @@ class TabManager { return this.tabs.find((t) => t.id === this.activeTabId); } + /** + * Serialize WINDOW state only: which files are open, the active tab, and + * per-tab UI (edit mode, split, scroll). Document content always lives on + * disk — the snapshot never carries rawContent, so unsaved changes are + * handled exclusively by the close dialogs, never smuggled through here. + * Untitled tabs have no disk backing and are resolved at close, so they + * are not persisted. + */ serializeState(): string { const stateData = { + version: 2, activeTabId: this.activeTabId, - tabs: this.tabs.map(t => ({ ...t, editorViewState: null, content: '' })) + tabs: this.tabs + .filter((t) => t.path !== '') + .map((t) => ({ + id: t.id, + path: t.path, + title: t.title, + isEditing: t.isEditing, + isSplit: t.isSplit, + splitRatio: t.splitRatio, + isScrollSynced: t.isScrollSynced, + scrollTop: t.scrollTop, + scrollPercentage: t.scrollPercentage, + anchorLine: t.anchorLine + })) }; return JSON.stringify(stateData); } + /** + * Rebuild clean tabs from a window-state snapshot. Content starts empty — + * the caller reads each file from disk afterwards. Also accepts the legacy + * full-tab format, from which only the window-state fields are taken + * (legacy untitled entries are dropped). + */ restoreState(jsonBuffer: string) { try { const data = JSON.parse(jsonBuffer); - if (data && Array.isArray(data.tabs)) { - this.tabs = data.tabs; - this.activeTabId = data.activeTabId; + if (!data || !Array.isArray(data.tabs)) return; + + const restored: Tab[] = []; + for (const saved of data.tabs) { + if (!saved || typeof saved.path !== 'string' || saved.path === '') continue; + const filename = saved.path.split('\\').pop()?.split('/').pop() || saved.path; + const fileHistory = createFileHistory(saved.path, ''); + restored.push({ + id: typeof saved.id === 'string' ? saved.id : crypto.randomUUID(), + path: saved.path, + title: typeof saved.title === 'string' && saved.title !== '' ? saved.title : filename, + content: '', + rawContent: '', + originalContent: '', + scrollTop: typeof saved.scrollTop === 'number' ? saved.scrollTop : 0, + isDirty: false, + isEditing: saved.isEditing === true, + history: fileHistory.history, + historyIndex: fileHistory.historyIndex, + editorViewState: null, + scrollPercentage: typeof saved.scrollPercentage === 'number' ? saved.scrollPercentage : 0, + anchorLine: typeof saved.anchorLine === 'number' ? saved.anchorLine : 0, + isSplit: saved.isSplit === true, + splitRatio: typeof saved.splitRatio === 'number' ? saved.splitRatio : 0.5, + isScrollSynced: saved.isScrollSynced === true + }); } + + this.tabs = restored; + this.activeTabId = restored.some((t) => t.id === data.activeTabId) + ? data.activeTabId + : restored[0]?.id ?? null; } catch (e) { console.error('Failed to restore tab state', e); } From 60c751fc1471f04619b06509183440035721fee4 Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Mon, 13 Jul 2026 00:25:58 +0900 Subject: [PATCH 06/19] fix: keep the close walk's dialog matched to the highlighted tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- scripts/windowClosePerTab.test.ts | 15 +++++ src/lib/MarkdownViewer.svelte | 94 +++++++++++++++++++------------ 2 files changed, 74 insertions(+), 35 deletions(-) diff --git a/scripts/windowClosePerTab.test.ts b/scripts/windowClosePerTab.test.ts index 01f134e..0414252 100644 --- a/scripts/windowClosePerTab.test.ts +++ b/scripts/windowClosePerTab.test.ts @@ -54,6 +54,21 @@ test('the window closes only after every dirty tab is resolved', () => { assert.ok(walk !== -1 && close !== -1 && walk < close); }); +test('a second close request cannot start a competing walk', () => { + const handler = closeHandler(); + // The native red button bypasses the dialog overlay; re-entry must be + // swallowed while a walk is active, or two walks fight over setActive + // and the highlighted tab stops matching the dialog. + assert.match(handler, /if \(isCloseWalkActive\) \{\s*event\.preventDefault\(\);\s*return;\s*\}/); + // and the flag is always released, even when the user cancels mid-walk + assert.match(handler, /finally \{\s*isCloseWalkActive = false;\s*\}/); +}); + +test('the walk starts from the tab the user is already looking at', () => { + const handler = closeHandler(); + assert.match(handler, /active\?\.isDirty\s*\?\s*active\s*:\s*tabManager\.tabs\.find\(\(t\) => t\.isDirty\)/); +}); + test('the restore-on-reopen branch is untouched original behavior', () => { const handler = closeHandler(); assert.match(handler, /localStorage\.setItem\('savedTabsData', stateStr\);/); diff --git a/src/lib/MarkdownViewer.svelte b/src/lib/MarkdownViewer.svelte index 069182c..057e27c 100644 --- a/src/lib/MarkdownViewer.svelte +++ b/src/lib/MarkdownViewer.svelte @@ -338,6 +338,10 @@ import { t } from './utils/i18n.js'; } let isForceExiting = $state(false); + // True while the window-close walk is showing per-tab dialogs; the native + // red button is not blocked by the dialog overlay, so this keeps a second + // close request from starting a competing walk. + let isCloseWalkActive = false; async function appExit() { if (settings.restoreStateOnReopen) { @@ -2525,6 +2529,17 @@ import { t } from './utils/i18n.js'; console.log('onCloseRequested triggered'); if (isForceExiting) return; + // The red button is a native control, so it is NOT blocked + // by the in-app dialog overlay: a second click while the + // walk below is showing a dialog would re-enter this handler + // and start a competing walk whose setActive calls fight the + // first one — the highlighted tab stops matching the dialog. + // One walk at a time. + if (isCloseWalkActive) { + event.preventDefault(); + return; + } + // Unsaved content and session restore are separate concerns: // dirty tabs are resolved FIRST through the per-tab dialogs, // then the restore snapshot records window state only (open @@ -2533,45 +2548,54 @@ import { t } from './utils/i18n.js'; const dirtyTabs = tabManager.tabs.filter((t) => t.isDirty); if (dirtyTabs.length > 0) { event.preventDefault(); - - // Auto-save without confirmation: silently save every - // dirty tab that has a real path. Untitled tabs need a - // Save dialog, so the walk below handles them. A failed - // silent save is surfaced and its tab also goes to the - // walk. Timers are cancelled per tab right before its - // save to avoid duplicate writes. - if (settings.autoSave && !settings.confirmBeforeSave) { - for (const tab of dirtyTabs.filter((t) => t.path !== '')) { - cancelPendingAutoSave(tab.id); - const ok = await saveContent(tab.id); - if (!ok) { - addToast(t('toast.autoSaveFailed', settings.language), 'error'); - break; + isCloseWalkActive = true; + try { + // Auto-save without confirmation: silently save every + // dirty tab that has a real path. Untitled tabs need a + // Save dialog, so the walk below handles them. A failed + // silent save is surfaced and its tab also goes to the + // walk. Timers are cancelled per tab right before its + // save to avoid duplicate writes. + if (settings.autoSave && !settings.confirmBeforeSave) { + for (const tab of dirtyTabs.filter((t) => t.path !== '')) { + cancelPendingAutoSave(tab.id); + const ok = await saveContent(tab.id); + if (!ok) { + addToast(t('toast.autoSaveFailed', settings.language), 'error'); + break; + } } } - } - // Close review (issue #189): walk the remaining dirty - // tabs one at a time — activate each and run the same - // localized unsaved-changes dialog a single tab close - // shows. Cancel stops the walk and keeps the window - // open. Re-find the next dirty tab every round — a save - // can leave a tab dirty again (TOCTOU) and tabs can - // change while a dialog is up. - while (true) { - const dirty = tabManager.tabs.find((t) => t.isDirty); - if (!dirty) break; - tabManager.setActive(dirty.id); - await tick(); - if (!(await canCloseTab(dirty.id))) return; - // Resolved tabs (saved, or reverted by Don't Save) - // stay open for the window-state snapshot when - // restore is enabled; untitled tabs have nothing to - // restore, and with restore off the red button - // closes tabs one by one. - if (!settings.restoreStateOnReopen || dirty.path === '') { - tabManager.closeTab(dirty.id); + // Close review (issue #189): walk the remaining dirty + // tabs one at a time — activate each and run the same + // localized unsaved-changes dialog a single tab close + // shows. Cancel stops the walk and keeps the window + // open. Prefer the tab the user is already looking at + // so the highlight only jumps when it has to, and + // re-find every round — a save can leave a tab dirty + // again (TOCTOU) and tabs can change while a dialog + // is up. + while (true) { + const active = tabManager.activeTab; + const dirty = active?.isDirty + ? active + : tabManager.tabs.find((t) => t.isDirty); + if (!dirty) break; + tabManager.setActive(dirty.id); + await tick(); + if (!(await canCloseTab(dirty.id))) return; + // Resolved tabs (saved, or reverted by Don't Save) + // stay open for the window-state snapshot when + // restore is enabled; untitled tabs have nothing to + // restore, and with restore off the red button + // closes tabs one by one. + if (!settings.restoreStateOnReopen || dirty.path === '') { + tabManager.closeTab(dirty.id); + } } + } finally { + isCloseWalkActive = false; } } From e6f3776fafb804b3d2c0874ad8be89045e65fcbf Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Mon, 13 Jul 2026 01:26:16 +0900 Subject: [PATCH 07/19] fix: keep v2 window-state snapshots invisible to legacy builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- scripts/windowClosePerTab.test.ts | 4 ++-- scripts/windowStateRestore.test.ts | 25 +++++++++++++++++++-- src/lib/MarkdownViewer.svelte | 35 ++++++++++++++++++++++-------- 3 files changed, 51 insertions(+), 13 deletions(-) diff --git a/scripts/windowClosePerTab.test.ts b/scripts/windowClosePerTab.test.ts index 0414252..c71ceae 100644 --- a/scripts/windowClosePerTab.test.ts +++ b/scripts/windowClosePerTab.test.ts @@ -69,9 +69,9 @@ test('the walk starts from the tab the user is already looking at', () => { assert.match(handler, /active\?\.isDirty\s*\?\s*active\s*:\s*tabManager\.tabs\.find\(\(t\) => t\.isDirty\)/); }); -test('the restore-on-reopen branch is untouched original behavior', () => { +test('the restore-on-reopen branch persists window state via the shared helper', () => { const handler = closeHandler(); - assert.match(handler, /localStorage\.setItem\('savedTabsData', stateStr\);/); + assert.match(handler, /persistWindowState\(\);/); // no durable-write experiment left behind assert.doesNotMatch(viewer, /saveSessionState|sessionState\.js/); }); diff --git a/scripts/windowStateRestore.test.ts b/scripts/windowStateRestore.test.ts index 2e3cabe..b2268e7 100644 --- a/scripts/windowStateRestore.test.ts +++ b/scripts/windowStateRestore.test.ts @@ -43,7 +43,7 @@ test('restoreState rebuilds clean tabs and drops legacy untitled entries', () => }); test('startup restore reads content from disk, not from the snapshot', () => { - const init = slice(viewer, "localStorage.getItem('savedTabsData')", 'urlParams'); + const init = slice(viewer, 'localStorage.getItem(WINDOW_STATE_KEY)', 'urlParams'); assert.match(init, /read_file_content/); // a missing file drops its tab instead of restoring a ghost assert.match(init, /closeTab\(/); @@ -59,12 +59,33 @@ test('the close flow resolves dirty tabs before serializing window state', () => const handlerStart = viewer.indexOf('appWindow.onCloseRequested'); const handler = viewer.slice(handlerStart, viewer.indexOf('onDragDropEvent', handlerStart)); const walk = handler.indexOf('canCloseTab(dirty.id)'); - const persist = handler.indexOf("localStorage.setItem('savedTabsData'"); + const persist = handler.indexOf('persistWindowState()'); assert.ok(walk !== -1, 'per-tab walk not found'); assert.ok(persist !== -1, 'window-state serialization not found'); assert.ok(walk < persist, 'dirty tabs must be resolved before the snapshot is written'); }); +test('v2 snapshots live under their own key, invisible to legacy builds', () => { + // An older build restoring a v2 snapshot ends up with undefined tab + // content; its editor then attributes a stale buffer to the wrong tab and + // auto-save writes it to disk. Separate keys keep the formats apart. + const helper = viewer.slice(viewer.indexOf('function persistWindowState')); + const scope = helper.slice(0, helper.indexOf('\n\t}')); + assert.match(scope, /setItem\(WINDOW_STATE_KEY/); + assert.match(scope, /removeItem\(LEGACY_STATE_KEY\)/); + assert.match(viewer, /const WINDOW_STATE_KEY = 'savedTabsDataV2';/); + // startup prefers the v2 key and falls back to legacy for migration + assert.match( + viewer, + /localStorage\.getItem\(WINDOW_STATE_KEY\) \?\? localStorage\.getItem\(LEGACY_STATE_KEY\)/, + ); + // explicit exit clears both + const exitFn = viewer.slice(viewer.indexOf('async function appExit')); + const exitScope = exitFn.slice(0, exitFn.indexOf('\n\t}')); + assert.match(exitScope, /removeItem\(WINDOW_STATE_KEY\)/); + assert.match(exitScope, /removeItem\(LEGACY_STATE_KEY\)/); +}); + test('with restore enabled resolved titled tabs stay open for the snapshot', () => { const handlerStart = viewer.indexOf('appWindow.onCloseRequested'); const handler = viewer.slice(handlerStart, viewer.indexOf('onDragDropEvent', handlerStart)); diff --git a/src/lib/MarkdownViewer.svelte b/src/lib/MarkdownViewer.svelte index 057e27c..e6efec6 100644 --- a/src/lib/MarkdownViewer.svelte +++ b/src/lib/MarkdownViewer.svelte @@ -343,6 +343,24 @@ import { t } from './utils/i18n.js'; // close request from starting a competing walk. let isCloseWalkActive = false; + // v2 window-state snapshots live under their own key, and the legacy key + // is removed on every write: an older Markpad build restoring a v2 + // snapshot it cannot understand ends up with undefined tab content, and + // its editor then attributes a stale buffer to the wrong tab — which + // auto-save happily writes to disk. Keeping the formats on separate keys + // makes old and new builds invisible to each other. + const WINDOW_STATE_KEY = 'savedTabsDataV2'; + const LEGACY_STATE_KEY = 'savedTabsData'; + + function persistWindowState() { + try { + localStorage.setItem(WINDOW_STATE_KEY, tabManager.serializeState()); + localStorage.removeItem(LEGACY_STATE_KEY); + } catch (e) { + console.error('Failed to save state on close:', e); + } + } + async function appExit() { if (settings.restoreStateOnReopen) { const hasUnsaved = tabManager.tabs.some((t) => t.isDirty || (t.path === '' && t.rawContent.trim() !== '')); @@ -354,7 +372,8 @@ import { t } from './utils/i18n.js'; }); if (response !== 'discard') return; } - localStorage.removeItem('savedTabsData'); + localStorage.removeItem(WINDOW_STATE_KEY); + localStorage.removeItem(LEGACY_STATE_KEY); isForceExiting = true; } appWindow.close(); @@ -1698,7 +1717,7 @@ import { t } from './utils/i18n.js'; async function destroyWindowAfterTabsClosed() { if (settings.restoreStateOnReopen) { - localStorage.setItem('savedTabsData', tabManager.serializeState()); + persistWindowState(); } await appWindow.destroy(); @@ -2373,7 +2392,10 @@ import { t } from './utils/i18n.js'; const appMode = (await invoke('get_app_mode')) as any; if (settings.restoreStateOnReopen) { - const savedData = localStorage.getItem('savedTabsData'); + // v2 key first; the legacy key is only read for one-time + // migration of a snapshot written by an older build. + const savedData = + localStorage.getItem(WINDOW_STATE_KEY) ?? localStorage.getItem(LEGACY_STATE_KEY); if (savedData) { tabManager.restoreState(savedData); // The snapshot carries window state only — content always @@ -2601,12 +2623,7 @@ import { t } from './utils/i18n.js'; // Session is clean now; record the window state for restore. if (settings.restoreStateOnReopen) { - try { - const stateStr = tabManager.serializeState(); - localStorage.setItem('savedTabsData', stateStr); - } catch (e) { - console.error('Failed to save state on close:', e); - } + persistWindowState(); } // If we intercepted the close to run the review, re-trigger From 85c129a6fdffa9a9f7098029f978f20ae5ae53fa Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Mon, 13 Jul 2026 01:38:31 +0900 Subject: [PATCH 08/19] feat: number untitled tabs so close dialogs can name them 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 --- scripts/untitledTitle.test.ts | 45 ++++++++++++++++++++++++++++++++++ src/lib/stores/tabs.svelte.ts | 13 ++++++++-- src/lib/utils/untitledTitle.ts | 24 ++++++++++++++++++ 3 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 scripts/untitledTitle.test.ts create mode 100644 src/lib/utils/untitledTitle.ts diff --git a/scripts/untitledTitle.test.ts b/scripts/untitledTitle.test.ts new file mode 100644 index 0000000..f87b283 --- /dev/null +++ b/scripts/untitledTitle.test.ts @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +import { nextUntitledTitle } from '../src/lib/utils/untitledTitle.js'; + +// Untitled tabs get distinct numbered titles ("Untitled 1", "Untitled 2", …) +// so the tab strip — and the per-tab close dialogs — can name exactly which +// tab they are talking about. Two dirty untitled tabs used to both be called +// "Untitled", making the close dialog ambiguous. + +test('the first untitled tab is number 1', () => { + assert.equal(nextUntitledTitle([], 'Untitled'), 'Untitled 1'); +}); + +test('numbers increment past the ones in use', () => { + assert.equal(nextUntitledTitle(['Untitled 1', 'Untitled 2'], 'Untitled'), 'Untitled 3'); +}); + +test('freed numbers are reused (smallest available wins)', () => { + assert.equal(nextUntitledTitle(['Untitled 1', 'Untitled 3'], 'Untitled'), 'Untitled 2'); +}); + +test('a legacy unnumbered title occupies slot 1', () => { + assert.equal(nextUntitledTitle(['Untitled'], 'Untitled'), 'Untitled 2'); +}); + +test('titled tabs and lookalike names are ignored', () => { + assert.equal( + nextUntitledTitle(['notes.md', 'Untitled draft', 'Untitled 1x'], 'Untitled'), + 'Untitled 1', + ); +}); + +test('works with localized bases', () => { + assert.equal(nextUntitledTitle(['无标题 1'], '无标题'), '无标题 2'); +}); + +test('new tabs are created with numbered untitled titles', () => { + const tabs = readFileSync('src/lib/stores/tabs.svelte.ts', 'utf8'); + assert.match(tabs, /nextUntitledTitle\(/); + // both creation paths go through the helper + const addNewTab = tabs.slice(tabs.indexOf('addNewTab()'), tabs.indexOf('addHomeTab()')); + assert.match(addNewTab, /nextUntitledTitle\(/); +}); diff --git a/src/lib/stores/tabs.svelte.ts b/src/lib/stores/tabs.svelte.ts index ec8226c..982362f 100644 --- a/src/lib/stores/tabs.svelte.ts +++ b/src/lib/stores/tabs.svelte.ts @@ -1,4 +1,5 @@ import { t } from '../utils/i18n.js'; +import { nextUntitledTitle } from '../utils/untitledTitle.js'; import { settings } from './settings.svelte.js'; import { canGoBackInHistory, @@ -132,7 +133,12 @@ class TabManager { addTab(path: string, content: string = '') { const id = crypto.randomUUID(); - const filename = path.split('\\').pop()?.split('/').pop() || t('tabs.untitled', settings.language); + const filename = + path.split('\\').pop()?.split('/').pop() || + nextUntitledTitle( + this.tabs.map((tab) => tab.title), + t('tabs.untitled', settings.language), + ); const fileHistory = createFileHistory(path, content); this.tabs.push({ @@ -165,7 +171,10 @@ class TabManager { this.tabs.push({ id, path: '', - title: t('tabs.untitled', settings.language), + title: nextUntitledTitle( + this.tabs.map((tab) => tab.title), + t('tabs.untitled', settings.language), + ), content, rawContent: content, originalContent: content, diff --git a/src/lib/utils/untitledTitle.ts b/src/lib/utils/untitledTitle.ts new file mode 100644 index 0000000..042d7b0 --- /dev/null +++ b/src/lib/utils/untitledTitle.ts @@ -0,0 +1,24 @@ +/** + * Numbered titles for untitled tabs ("Untitled 1", "Untitled 2", …). + * + * Untitled tabs used to share one identical title, so any UI that names a + * tab — the tab strip, and especially the per-tab unsaved-changes dialog at + * window close — could not tell the user which tab it was talking about. + * The smallest free number is reused, matching common editor behavior. + */ +export function nextUntitledTitle(existingTitles: readonly string[], base: string): string { + const used = new Set(); + for (const title of existingTitles) { + if (title === base) { + // A legacy unnumbered tab occupies slot 1. + used.add(1); + continue; + } + if (!title.startsWith(base + ' ')) continue; + const suffix = title.slice(base.length + 1); + if (/^[1-9][0-9]*$/.test(suffix)) used.add(Number(suffix)); + } + let n = 1; + while (used.has(n)) n++; + return `${base} ${n}`; +} From 7c0e76dbc8be14f65ee590e4678e584d50074e6a Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Mon, 13 Jul 2026 01:46:12 +0900 Subject: [PATCH 09/19] fix: walk dirty tabs in tab-strip order and name them in Save As MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- scripts/windowClosePerTab.test.ts | 13 +++++++++++-- src/lib/MarkdownViewer.svelte | 19 +++++++++---------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/scripts/windowClosePerTab.test.ts b/scripts/windowClosePerTab.test.ts index c71ceae..dbcad0c 100644 --- a/scripts/windowClosePerTab.test.ts +++ b/scripts/windowClosePerTab.test.ts @@ -64,9 +64,18 @@ test('a second close request cannot start a competing walk', () => { assert.match(handler, /finally \{\s*isCloseWalkActive = false;\s*\}/); }); -test('the walk starts from the tab the user is already looking at', () => { +test('the walk proceeds in strict tab-strip order', () => { const handler = closeHandler(); - assert.match(handler, /active\?\.isDirty\s*\?\s*active\s*:\s*tabManager\.tabs\.find\(\(t\) => t\.isDirty\)/); + // Predictable left-to-right order: always the first dirty tab in the + // array; no active-first shortcut that made the sequence look random. + assert.match(handler, /const dirty = tabManager\.tabs\.find\(\(t\) => t\.isDirty\);/); + assert.doesNotMatch(handler, /active\?\.isDirty/); +}); + +test('the untitled save dialog prefills the numbered tab title', () => { + const fn = viewer.slice(viewer.indexOf('async function saveContent')); + const scope = fn.slice(0, fn.indexOf('async function saveContentAs')); + assert.match(scope, /defaultPath: tab\.title/); }); test('the restore-on-reopen branch persists window state via the shared helper', () => { diff --git a/src/lib/MarkdownViewer.svelte b/src/lib/MarkdownViewer.svelte index e6efec6..051d9c3 100644 --- a/src/lib/MarkdownViewer.svelte +++ b/src/lib/MarkdownViewer.svelte @@ -1449,12 +1449,14 @@ import { t } from './utils/i18n.js'; let targetPath = tab.path; if (!targetPath) { - // Special handling for new (untitled) files + // Special handling for new (untitled) files. Prefill the numbered + // tab title so the dialog itself names which tab is being saved. const selected = await save({ filters: [ { name: 'Markdown', extensions: ['md'] }, { name: 'All Files', extensions: ['*'] }, ], + defaultPath: tab.title, }); if (selected) { targetPath = selected; @@ -2593,16 +2595,13 @@ import { t } from './utils/i18n.js'; // tabs one at a time — activate each and run the same // localized unsaved-changes dialog a single tab close // shows. Cancel stops the walk and keeps the window - // open. Prefer the tab the user is already looking at - // so the highlight only jumps when it has to, and - // re-find every round — a save can leave a tab dirty - // again (TOCTOU) and tabs can change while a dialog - // is up. + // open. Strict tab-strip order (left to right) so the + // sequence is predictable; numbered untitled titles + // let the dialog name each tab. Re-find every round — + // a save can leave a tab dirty again (TOCTOU) and + // tabs can change while a dialog is up. while (true) { - const active = tabManager.activeTab; - const dirty = active?.isDirty - ? active - : tabManager.tabs.find((t) => t.isDirty); + const dirty = tabManager.tabs.find((t) => t.isDirty); if (!dirty) break; tabManager.setActive(dirty.id); await tick(); From fe9d7714712044c0e2daba6e70a861e9edbca439 Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Mon, 13 Jul 2026 09:52:07 +0900 Subject: [PATCH 10/19] feat: broker tab transfers, per-window delivery, and Rust window-state persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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-' 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 --- src-tauri/capabilities/default.json | 5 +- src-tauri/src/lib.rs | 168 ++++++++++++++++++++++++---- src-tauri/src/tab_transfer.rs | 143 +++++++++++++++++++++++ 3 files changed, 292 insertions(+), 24 deletions(-) create mode 100644 src-tauri/src/tab_transfer.rs diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 4fe2319..955f675 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -1,10 +1,11 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", - "description": "Capability for the main window", + "description": "Capability for all Markpad windows", "windows": [ "main", - "installer" + "installer", + "window-*" ], "permissions": [ "core:default", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 542b325..0dcfbb7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -135,10 +135,11 @@ mod tests { } struct WatcherState { - watcher: Mutex>, + watchers: Mutex>, } mod setup; +mod tab_transfer; #[tauri::command] async fn show_window(window: tauri::Window) { @@ -147,12 +148,108 @@ async fn show_window(window: tauri::Window) { let _ = window.set_focus(); } +// Window-state snapshots are written through Rust instead of localStorage: +// setItem is an async message to the WebKit storage process and dies in +// transit when the last window's close ends the process, whereas an +// awaited invoke keeps the close handler — and therefore the process — +// alive until the bytes are on disk. +fn window_state_path(app: &AppHandle) -> Result { + let dir = app + .path() + .app_config_dir() + .map_err(|e| e.to_string())?; + fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + Ok(dir.join("window-state-v2.json")) +} + +#[tauri::command] +fn save_window_state(app: AppHandle, json: String) -> Result<(), String> { + fs::write(window_state_path(&app)?, json).map_err(|e| e.to_string()) +} + +#[tauri::command] +fn load_window_state(app: AppHandle) -> Option { + fs::read_to_string(window_state_path(&app).ok()?).ok() +} + +#[tauri::command] +fn clear_window_state(app: AppHandle) -> Result<(), String> { + let path = window_state_path(&app)?; + if path.exists() { + fs::remove_file(path).map_err(|e| e.to_string())?; + } + Ok(()) +} + fn bring_webview_window_to_front(window: &tauri::WebviewWindow) { let _ = window.show(); let _ = window.unminimize(); let _ = window.set_focus(); } +/// Picks the viewer window that should receive an externally opened file: +/// the focused viewer if any, otherwise any viewer. Viewer windows are +/// "main" and detached "window-*" windows; "installer" never receives files. +fn pick_delivery_window(app: &AppHandle) -> Option { + let viewers: Vec = app + .webview_windows() + .into_iter() + .filter(|(label, _)| label == "main" || label.starts_with("window-")) + .map(|(_, window)| window) + .collect(); + + viewers + .iter() + .find(|window| window.is_focused().unwrap_or(false)) + .cloned() + .or_else(|| viewers.into_iter().next()) +} + +/// Creates the destination window for a tab transfer. The window's label +/// embeds the transfer token ("window-"), so the new frontend can +/// derive which pending transfer to claim from its own label — no URL +/// query involved (the asset protocol 404s on "index.html?x=y" paths). +/// Deliberately NOT async: sync commands run on the main thread, which +/// window creation requires on macOS. +#[tauri::command] +fn create_transfer_window(app: AppHandle, token: String) -> Result<(), String> { + let label = format!("window-{token}"); + + #[allow(unused_mut)] + let mut window_builder = tauri::WebviewWindowBuilder::new( + &app, + &label, + tauri::WebviewUrl::App("index.html".into()), + ) + .title("Markpad") + .inner_size(1000.0, 800.0) + .min_inner_size(400.0, 300.0) + .visible(false) + .resizable(true); + + #[cfg(target_os = "macos")] + { + // Decorated macOS windows keep their shadow. The main window's + // shadow(false) is resurrected as a side effect of the window-state + // plugin restoring its frame at startup; a fresh secondary window + // gets no such restore, so it must opt in explicitly or it renders + // shadowless and blends into the window behind it. + window_builder = window_builder + .decorations(true) + .title_bar_style(tauri::TitleBarStyle::Overlay) + .hidden_title(true) + .shadow(true); + } + + #[cfg(not(target_os = "macos"))] + { + window_builder = window_builder.decorations(false).shadow(false); + } + + window_builder.build().map_err(|e| e.to_string())?; + Ok(()) +} + fn process_internal_embeds(content: &str) -> Cow<'_, str> { let re = Regex::new(r"(?s)```.*?```|`.*?`|!\[\[(.*?)\]\]").unwrap(); @@ -400,21 +497,24 @@ fn rename_file(old_path: String, new_path: String) -> Result<(), String> { #[tauri::command] fn watch_file( + window: tauri::Window, handle: AppHandle, state: State<'_, WatcherState>, path: String, ) -> Result<(), String> { - let mut watcher_lock = state.watcher.lock().unwrap(); + let label = window.label().to_string(); + let mut watchers_lock = state.watchers.lock().unwrap(); - *watcher_lock = None; + watchers_lock.remove(&label); let path_to_watch = path.clone(); let app_handle = handle.clone(); + let event_label = label.clone(); let mut watcher = RecommendedWatcher::new( move |res: Result| { if let Ok(_) = res { - let _ = app_handle.emit("file-changed", ()); + let _ = app_handle.emit_to(event_label.as_str(), "file-changed", ()); } }, Config::default(), @@ -425,15 +525,15 @@ fn watch_file( .watch(Path::new(&path_to_watch), RecursiveMode::NonRecursive) .map_err(|e| e.to_string())?; - *watcher_lock = Some(watcher); + watchers_lock.insert(label, watcher); Ok(()) } #[tauri::command] -fn unwatch_file(state: State<'_, WatcherState>) -> Result<(), String> { - let mut watcher_lock = state.watcher.lock().unwrap(); - *watcher_lock = None; +fn unwatch_file(window: tauri::Window, state: State<'_, WatcherState>) -> Result<(), String> { + let mut watchers_lock = state.watchers.lock().unwrap(); + watchers_lock.remove(window.label()); Ok(()) } @@ -921,13 +1021,14 @@ pub fn run() { startup_file: Mutex::new(None), }) .manage(WatcherState { - watcher: Mutex::new(None), + watchers: Mutex::new(std::collections::HashMap::new()), }) + .manage(tab_transfer::TabTransferBroker::new()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_single_instance::init(|app, args, cwd| { println!("Single Instance Args: {:?}", args); - let Some(window) = app.get_webview_window("main") else { + let Some(window) = pick_delivery_window(app) else { return; }; @@ -947,7 +1048,7 @@ pub fn run() { cwd_path.join(path).display().to_string() }; - let _ = window.emit("file-path", resolved_path); + let _ = app.emit_to(window.label(), "file-path", resolved_path); } bring_webview_window_to_front(&window); })) @@ -963,6 +1064,15 @@ pub fn run() { | tauri_plugin_window_state::StateFlags::VISIBLE | tauri_plugin_window_state::StateFlags::FULLSCREEN, ) + // Detached tab windows share one saved state instead of + // accumulating a state entry per generated label. + .map_label(|label| { + if label.starts_with("window-") { + "secondary" + } else { + label + } + }) .build(), ) .setup(|app| { @@ -1197,16 +1307,30 @@ pub fn run() { delete_file, copy_file, cleanup_empty_img_dir, - list_directory_contents + list_directory_contents, + tab_transfer::stage_detached_tab, + tab_transfer::claim_detached_tab, + tab_transfer::cancel_detached_tab, + create_transfer_window, + save_window_state, + load_window_state, + clear_window_state ]) + .on_window_event(|window, event| { + if let tauri::WindowEvent::Destroyed = event { + // Drop this window's file watcher so a closed window never + // leaves a dangling notify handle behind. + let state = window.state::(); + state.watchers.lock().unwrap().remove(window.label()); + } + }) .on_menu_event(|app, event| { let id = event.id().as_ref(); - // Emit to the focused webview window rather than `app.emit(...)`, - // which would broadcast to every webview. Markpad is currently - // single-window, but additional webviews (e.g. detached tabs) - // would otherwise receive duplicate New/Close/Save invocations. - // Falls back to "main" if no window is focused (e.g. menu fired - // while the app is in the background). + // Emit to the focused webview window's label rather than + // `window.emit(...)`, which broadcasts to every webview and + // would fire menu actions (New/Close/Save…) in all windows at + // once. Falls back to "main" if no window is focused (e.g. menu + // fired while the app is in the background). let target = app .webview_windows() .into_values() @@ -1215,12 +1339,12 @@ pub fn run() { let Some(window) = target else { return }; if id == "check-updates" { - let _ = window.emit("menu-check-updates", ()); + let _ = app.emit_to(window.label(), "menu-check-updates", ()); } else if id == "menu-app-quit" || id.starts_with("menu-file-") || id.starts_with("menu-edit-") { - let _ = window.emit(id, ()); + let _ = app.emit_to(window.label(), id, ()); } }) .build(tauri::generate_context!()) @@ -1235,8 +1359,8 @@ pub fn run() { let state = _app_handle.state::(); *state.startup_file.lock().unwrap() = Some(path_str.clone()); - if let Some(window) = _app_handle.get_webview_window("main") { - let _ = window.emit("file-path", path_str); + if let Some(window) = pick_delivery_window(_app_handle) { + let _ = _app_handle.emit_to(window.label(), "file-path", path_str); bring_webview_window_to_front(&window); } } diff --git a/src-tauri/src/tab_transfer.rs b/src-tauri/src/tab_transfer.rs new file mode 100644 index 0000000..7e944f6 --- /dev/null +++ b/src-tauri/src/tab_transfer.rs @@ -0,0 +1,143 @@ +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Mutex; +use tauri::{AppHandle, Emitter, State}; + +/// Maximum number of staged transfers kept in memory; oldest entries are +/// evicted first so an abandoned drag can never grow the map unbounded. +const MAX_PENDING_TRANSFERS: usize = 16; + +pub struct PendingTransfer { + pub payload: String, + pub source_label: String, +} + +#[derive(Default)] +struct BrokerInner { + entries: HashMap, + insertion_order: Vec, +} + +/// In-memory broker for moving a tab between windows: the source window +/// stages a JSON snapshot and hands the returned token to the new window, +/// which claims the payload exactly once. +pub struct TabTransferBroker { + inner: Mutex, + counter: AtomicU64, +} + +impl TabTransferBroker { + pub fn new() -> Self { + TabTransferBroker { + inner: Mutex::new(BrokerInner::default()), + counter: AtomicU64::new(0), + } + } + + fn stage(&self, payload: String, source_label: String) -> String { + let token = format!("t{}", self.counter.fetch_add(1, Ordering::Relaxed) + 1); + let mut inner = self.inner.lock().unwrap(); + inner.entries.insert( + token.clone(), + PendingTransfer { + payload, + source_label, + }, + ); + inner.insertion_order.push(token.clone()); + while inner.entries.len() > MAX_PENDING_TRANSFERS { + let oldest = inner.insertion_order.remove(0); + inner.entries.remove(&oldest); + } + token + } + + fn claim(&self, token: &str) -> Option { + let mut inner = self.inner.lock().unwrap(); + inner.insertion_order.retain(|t| t != token); + inner.entries.remove(token) + } + + fn cancel(&self, token: &str) { + self.claim(token); + } +} + +#[tauri::command] +pub fn stage_detached_tab( + window: tauri::Window, + state: State<'_, TabTransferBroker>, + payload: String, +) -> String { + state.stage(payload, window.label().to_string()) +} + +#[tauri::command] +pub fn claim_detached_tab( + app: AppHandle, + state: State<'_, TabTransferBroker>, + token: String, +) -> Option { + let transfer = state.claim(&token)?; + let _ = app.emit_to( + transfer.source_label.as_str(), + "tab-transfer-claimed", + token, + ); + Some(transfer.payload) +} + +#[tauri::command] +pub fn cancel_detached_tab(state: State<'_, TabTransferBroker>, token: String) { + state.cancel(&token); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stage_then_claim_returns_payload_and_removes_entry() { + let broker = TabTransferBroker::new(); + let token = broker.stage("{\"tab\":1}".to_string(), "main".to_string()); + + let transfer = broker.claim(&token).expect("staged transfer should exist"); + assert_eq!(transfer.payload, "{\"tab\":1}"); + assert_eq!(transfer.source_label, "main"); + + assert!(broker.claim(&token).is_none()); + } + + #[test] + fn cancel_then_claim_returns_none() { + let broker = TabTransferBroker::new(); + let token = broker.stage("{}".to_string(), "window-1".to_string()); + + broker.cancel(&token); + assert!(broker.claim(&token).is_none()); + } + + #[test] + fn tokens_are_unique_across_stages() { + let broker = TabTransferBroker::new(); + let first = broker.stage("a".to_string(), "main".to_string()); + let second = broker.stage("b".to_string(), "main".to_string()); + assert_ne!(first, second); + } + + #[test] + fn staging_beyond_cap_evicts_oldest() { + let broker = TabTransferBroker::new(); + let mut tokens = Vec::new(); + for i in 0..=MAX_PENDING_TRANSFERS { + tokens.push(broker.stage(format!("payload-{}", i), "main".to_string())); + } + + // The very first entry was evicted; every later one is still claimable. + assert!(broker.claim(&tokens[0]).is_none()); + for (i, token) in tokens.iter().enumerate().skip(1) { + let transfer = broker.claim(token).expect("entry within cap should remain"); + assert_eq!(transfer.payload, format!("payload-{}", i)); + } + } +} From 59a165a32ce47ae20ee30bfa8c41ae07f55cc0cf Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Mon, 13 Jul 2026 09:52:22 +0900 Subject: [PATCH 11/19] feat: serialize tabs for cross-window transfer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- scripts/tabTransfer.test.ts | 219 ++++++++++++++++++++++++++++++++++ src/lib/stores/tabs.svelte.ts | 19 +++ src/lib/utils/i18n.ts | 26 ++++ src/lib/utils/tabTransfer.ts | 167 ++++++++++++++++++++++++++ 4 files changed, 431 insertions(+) create mode 100644 scripts/tabTransfer.test.ts create mode 100644 src/lib/utils/tabTransfer.ts diff --git a/scripts/tabTransfer.test.ts b/scripts/tabTransfer.test.ts new file mode 100644 index 0000000..bac40d2 --- /dev/null +++ b/scripts/tabTransfer.test.ts @@ -0,0 +1,219 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +import type { Tab } from '../src/lib/stores/tabs.svelte.js'; +import { + buildTransferredTab, + snapshotTab, + transferredTabTitle, + validateTransferPayload, + type TransferableTab, +} from '../src/lib/utils/tabTransfer.js'; + +// Cross-window tab transfer: snapshot in the source window → JSON through +// the Rust broker → strict validation → rebuild in the destination. The +// snapshot is independent of serializeState (window shape only, no content): +// a transferred tab MUST carry its unsaved buffer. Validation is strict and +// rejecting — a past bug built tabs with undefined rawContent, the editor +// attributed a stale buffer to them, and auto-save destroyed real files. + +function makeTab(overrides: Partial = {}): Tab { + return { + id: 'source-id', + path: '/notes/todo.md', + title: 'todo.md', + content: '

rendered

', + rawContent: '# unsaved edits', + originalContent: '# saved on disk', + scrollTop: 120, + isDirty: true, + isEditing: true, + history: ['/notes/readme.md', '/notes/todo.md'], + historyIndex: 1, + editorViewState: { cursorState: 'monaco-live-object' }, + scrollPercentage: 0.42, + anchorLine: 7, + isSplit: true, + splitRatio: 0.3, + isScrollSynced: true, + ...overrides, + }; +} + +function roundTrip(tab: Tab): TransferableTab { + const snap = snapshotTab(tab); + const validated = validateTransferPayload(JSON.stringify(snap)); + assert.ok(validated, 'a snapshot of a real tab must validate'); + return validated; +} + +test('snapshot → JSON → validate → insert preserves the tab, including unsaved content', () => { + const source = makeTab(); + const arrived = buildTransferredTab(roundTrip(source), [], 'Untitled'); + + assert.equal(arrived.path, source.path); + assert.equal(arrived.title, source.title); + assert.equal(arrived.rawContent, source.rawContent); + assert.equal(arrived.originalContent, source.originalContent); + assert.equal(arrived.isDirty, true); + assert.equal(arrived.isEditing, true); + assert.equal(arrived.isSplit, true); + assert.equal(arrived.splitRatio, source.splitRatio); + assert.equal(arrived.isScrollSynced, true); + assert.equal(arrived.scrollTop, source.scrollTop); + assert.equal(arrived.scrollPercentage, source.scrollPercentage); + assert.equal(arrived.anchorLine, source.anchorLine); + assert.deepEqual(arrived.history, source.history); + assert.equal(arrived.historyIndex, source.historyIndex); + + // regenerated / non-serializable state starts fresh at the destination + assert.equal(arrived.content, ''); + assert.equal(arrived.editorViewState, null); + assert.notEqual(arrived.id, source.id); +}); + +test('each insert gets a fresh id', () => { + const snap = snapshotTab(makeTab()); + const a = buildTransferredTab(snap, [], 'Untitled'); + const b = buildTransferredTab(snap, [], 'Untitled'); + assert.notEqual(a.id, b.id); +}); + +test('snapshot never mutates the source tab', () => { + const source = makeTab(); + const snap = snapshotTab(source); + + snap.history.push('/injected.md'); + snap.rawContent = 'clobbered'; + snap.title = 'clobbered'; + + assert.deepEqual(source.history, ['/notes/readme.md', '/notes/todo.md']); + assert.equal(source.rawContent, '# unsaved edits'); + assert.equal(source.title, 'todo.md'); + assert.equal(source.editorViewState.cursorState, 'monaco-live-object'); +}); + +test('the snapshot excludes rendered content and editorViewState', () => { + const snap = snapshotTab(makeTab()) as Record; + assert.ok(!('content' in snap)); + assert.ok(!('editorViewState' in snap)); + assert.ok(!('id' in snap)); +}); + +test('validation rejects invalid JSON', () => { + assert.equal(validateTransferPayload('not json {'), null); + assert.equal(validateTransferPayload(''), null); +}); + +test('validation rejects non-object payloads', () => { + assert.equal(validateTransferPayload('null'), null); + assert.equal(validateTransferPayload('[]'), null); + assert.equal(validateTransferPayload('"a tab"'), null); +}); + +test('validation rejects a missing rawContent — no default, no coercion', () => { + const snap = snapshotTab(makeTab()) as Record; + delete snap.rawContent; + assert.equal(validateTransferPayload(JSON.stringify(snap)), null); +}); + +test('validation rejects a non-string rawContent', () => { + const snap = snapshotTab(makeTab()) as Record; + snap.rawContent = 5; + assert.equal(validateTransferPayload(JSON.stringify(snap)), null); +}); + +test('validation rejects a history that is not an array', () => { + const snap = snapshotTab(makeTab()) as Record; + snap.history = 'nope'; + assert.equal(validateTransferPayload(JSON.stringify(snap)), null); +}); + +test('validation rejects history entries that are not strings', () => { + const snap = snapshotTab(makeTab()) as Record; + snap.history = [1, 2]; + assert.equal(validateTransferPayload(JSON.stringify(snap)), null); +}); + +test('validation is strict for every field — even cosmetic numerics get no default', () => { + for (const field of [ + 'path', + 'title', + 'originalContent', + 'isDirty', + 'isEditing', + 'isSplit', + 'isScrollSynced', + 'splitRatio', + 'scrollTop', + 'scrollPercentage', + 'anchorLine', + 'historyIndex', + ]) { + const snap = snapshotTab(makeTab()) as Record; + delete snap[field]; + assert.equal(validateTransferPayload(JSON.stringify(snap)), null, `missing ${field}`); + } +}); + +test('validation rejects non-finite numbers (NaN serializes to null)', () => { + const snap = snapshotTab(makeTab()) as Record; + snap.scrollTop = NaN; // JSON.stringify turns this into null + assert.equal(validateTransferPayload(JSON.stringify(snap)), null); +}); + +test('untitled arrivals keep their title unless the destination has taken it', () => { + const source = makeTab({ path: '', title: 'Untitled 2' }); + const snap = roundTrip(source); + + // The title is the document's identity: a fresh detach window (or any + // destination without a clash) never renames. + assert.equal(transferredTabTitle(snap, [], 'Untitled'), 'Untitled 2'); + assert.equal(transferredTabTitle(snap, ['Untitled 1'], 'Untitled'), 'Untitled 2'); + assert.equal(transferredTabTitle(snap, ['notes.md'], 'Untitled'), 'Untitled 2'); + + // Only an exact clash re-numbers, to the destination's smallest free + // number per untitledTitle semantics. + assert.equal( + transferredTabTitle(snap, ['Untitled 1', 'Untitled 2'], 'Untitled'), + 'Untitled 3', + ); + assert.equal(transferredTabTitle(snap, ['Untitled 2'], 'Untitled'), 'Untitled 1'); + + const arrived = buildTransferredTab(snap, ['Untitled 1', 'Untitled 2'], 'Untitled'); + assert.equal(arrived.title, 'Untitled 3'); + // the buffer still travels even when the title changed + assert.equal(arrived.rawContent, source.rawContent); +}); + +test('file-backed arrivals keep their title', () => { + const snap = roundTrip(makeTab()); + assert.equal(transferredTabTitle(snap, ['todo.md'], 'Untitled'), 'todo.md'); +}); + +// insertTransferredTab lives in the Svelte store ($state rune, not loadable +// in node), so — like windowStateRestore.test.ts — the thin store wrapper is +// checked statically; all decision logic above is exercised directly. +test('insertTransferredTab is a thin wrapper: build, push, activate, return the id', () => { + const tabs = readFileSync('src/lib/stores/tabs.svelte.ts', 'utf8'); + const start = tabs.indexOf('insertTransferredTab('); + assert.ok(start !== -1, 'insertTransferredTab not found'); + const fn = tabs.slice(start, tabs.indexOf('closeTab(', start)); + + assert.match(fn, /buildTransferredTab\(/); + // untitled re-numbering uses this window's titles and localized base + assert.match(fn, /this\.tabs\.map\(\(tab\) => tab\.title\)/); + assert.match(fn, /t\('tabs\.untitled', settings\.language\)/); + // the new tab is pushed, activated, and its id returned + assert.match(fn, /this\.tabs\.push\(tab\);/); + assert.match(fn, /this\.activeTabId = tab\.id;/); + assert.match(fn, /return tab\.id;/); +}); + +test('serializeState still persists window shape only (untouched by transfer)', () => { + const tabs = readFileSync('src/lib/stores/tabs.svelte.ts', 'utf8'); + const fn = tabs.slice(tabs.indexOf('serializeState()'), tabs.indexOf('restoreState(')); + assert.doesNotMatch(fn, /rawContent/); + assert.doesNotMatch(fn, /TransferableTab/); +}); diff --git a/src/lib/stores/tabs.svelte.ts b/src/lib/stores/tabs.svelte.ts index 982362f..5458c6c 100644 --- a/src/lib/stores/tabs.svelte.ts +++ b/src/lib/stores/tabs.svelte.ts @@ -1,6 +1,7 @@ import { t } from '../utils/i18n.js'; import { nextUntitledTitle } from '../utils/untitledTitle.js'; import { settings } from './settings.svelte.js'; +import { buildTransferredTab, type TransferableTab } from '../utils/tabTransfer.js'; import { canGoBackInHistory, canGoForwardInHistory, @@ -225,6 +226,24 @@ class TabManager { this.activeTabId = id; } + /** + * Insert a tab that arrived from another window (cross-window transfer). + * The snapshot carries the unsaved buffer — see tabTransfer.ts. Rendered + * content starts empty (the caller re-renders); untitled arrivals are + * re-numbered against THIS window's tabs. Independent of serializeState/ + * restoreState, which persist window shape only. + */ + insertTransferredTab(snap: TransferableTab): string { + const tab = buildTransferredTab( + snap, + this.tabs.map((tab) => tab.title), + t('tabs.untitled', settings.language), + ); + this.tabs.push(tab); + this.activeTabId = tab.id; + return tab.id; + } + closeTab(id: string) { const index = this.tabs.findIndex((t) => t.id === id); if (index === -1) return; diff --git a/src/lib/utils/i18n.ts b/src/lib/utils/i18n.ts index a38d55a..54199d5 100644 --- a/src/lib/utils/i18n.ts +++ b/src/lib/utils/i18n.ts @@ -150,6 +150,7 @@ export const translations: Record = { reloadFromDisk: 'Reload from Disk', closeFile: 'Close File', closeWindow: 'Close Window', + moveToNewWindow: 'Move to New Window', undo: 'Undo', redo: 'Redo', cut: 'Cut', @@ -464,6 +465,7 @@ export const translations: Record = { saveAs: '另存为', closeFile: '关闭文件', closeWindow: '关闭窗口', + moveToNewWindow: '移动到新窗口', undo: '撤销', redo: '重做', cut: '剪切', @@ -726,6 +728,7 @@ export const translations: Record = { saveAs: '名前を付けて保存', closeFile: 'ファイルを閉じる', closeWindow: 'ウィンドウを閉じる', + moveToNewWindow: '新しいウィンドウに移動', undo: '元に戻す', redo: 'やり直し', cut: '切り取り', @@ -988,6 +991,7 @@ export const translations: Record = { saveAs: '另存為', closeFile: '關閉文件', closeWindow: '關閉窗口', + moveToNewWindow: '移動到新窗口', undo: '撤銷', redo: '重做', cut: '剪切', @@ -1221,6 +1225,7 @@ export const translations: Record = { saveAs: '다른 이름으로 저장', closeFile: '파일 닫기', closeWindow: '창 닫기', + moveToNewWindow: '새 창으로 이동', undo: '실행 취소', redo: '다시 실행', cut: '잘라내기', @@ -1436,6 +1441,7 @@ export const translations: Record = { saveAs: 'Сохранить как', closeFile: 'Закрыть файл', closeWindow: 'Закрыть окно', + moveToNewWindow: 'Переместить в новое окно', undo: 'Отменить', redo: 'Повторить', cut: 'Вырезать', @@ -1651,6 +1657,7 @@ export const translations: Record = { saveAs: 'Guardar como', closeFile: 'Cerrar archivo', closeWindow: 'Cerrar ventana', + moveToNewWindow: 'Mover a nueva ventana', undo: 'Deshacer', redo: 'Rehacer', cut: 'Cortar', @@ -1866,6 +1873,7 @@ export const translations: Record = { saveAs: 'Enregistrer sous', closeFile: 'Fermer le fichier', closeWindow: 'Fermer la fenêtre', + moveToNewWindow: 'Déplacer vers une nouvelle fenêtre', undo: 'Annuler', redo: 'Rétablir', cut: 'Couper', @@ -2081,6 +2089,7 @@ export const translations: Record = { saveAs: 'Speichern unter', closeFile: 'Datei schließen', closeWindow: 'Fenster schließen', + moveToNewWindow: 'In neues Fenster verschieben', undo: 'Rückgängig', redo: 'Wiederholen', cut: 'Ausschneiden', @@ -2296,6 +2305,7 @@ export const translations: Record = { saveAs: 'Salvar como', closeFile: 'Fechar arquivo', closeWindow: 'Fechar janela', + moveToNewWindow: 'Mover para nova janela', undo: 'Desfazer', redo: 'Refazer', cut: 'Recortar', @@ -2511,6 +2521,7 @@ export const translations: Record = { saveAs: 'Salva come', closeFile: 'Chiudi file', closeWindow: 'Chiudi finestra', + moveToNewWindow: 'Sposta in una nuova finestra', undo: 'Annulla', redo: 'Ripristina', cut: 'Taglia', @@ -2737,6 +2748,7 @@ export const translations: Record = { saveAs: 'Zapisz jako', closeFile: 'Zamknij plik', closeWindow: 'Zamknij okno', + moveToNewWindow: 'Przenieś do nowego okna', undo: 'Cofnij', redo: 'Ponów', cut: 'Wytnij', @@ -2962,6 +2974,7 @@ export const translations: Record = { saveAs: 'Opslaan als', closeFile: 'Sluit bestand', closeWindow: 'Sluit venster', + moveToNewWindow: 'Verplaats naar nieuw venster', undo: 'Ongedaan maken', redo: 'Opnieuw', cut: 'Knippen', @@ -3177,6 +3190,7 @@ export const translations: Record = { saveAs: 'Spara som', closeFile: 'Stäng fil', closeWindow: 'Stäng fönster', + moveToNewWindow: 'Flytta till nytt fönster', undo: 'Ångra', redo: 'Gör om', cut: 'Klipp ut', @@ -3392,6 +3406,7 @@ export const translations: Record = { saveAs: 'Lưu dưới dạng', closeFile: 'Đóng tệp', closeWindow: 'Đóng cửa sổ', + moveToNewWindow: 'Chuyển sang cửa sổ mới', undo: 'Hoàn tác', redo: 'Làm lại', cut: 'Cắt', @@ -3607,6 +3622,7 @@ export const translations: Record = { saveAs: 'Guardar como', closeFile: 'Fechar ficheiro', closeWindow: 'Fechar janela', + moveToNewWindow: 'Mover para nova janela', undo: 'Desfazer', redo: 'Refazer', cut: 'Cortar', @@ -3822,6 +3838,7 @@ export const translations: Record = { saveAs: 'Salvare ca', closeFile: 'Închidere fișier', closeWindow: 'Închidere fereastră', + moveToNewWindow: 'Mutare într-o fereastră nouă', undo: 'Anulare', redo: 'Refacere', cut: 'Decupare', @@ -4037,6 +4054,7 @@ export const translations: Record = { saveAs: 'Mentés másként', closeFile: 'Fájl bezárása', closeWindow: 'Ablak bezárása', + moveToNewWindow: 'Áthelyezés új ablakba', undo: 'Visszavonás', redo: 'Újra', cut: 'Kivágás', @@ -4252,6 +4270,7 @@ export const translations: Record = { saveAs: 'Uložit jako', closeFile: 'Zavřít soubor', closeWindow: 'Zavřít okno', + moveToNewWindow: 'Přesunout do nového okna', undo: 'Zpět', redo: 'Znovu', cut: 'Vyjmout', @@ -4473,6 +4492,7 @@ export const translations: Record = { saveAs: 'Uložiť ako', closeFile: 'Zatvoriť súbor', closeWindow: 'Zatvoriť okno', + moveToNewWindow: 'Presunúť do nového okna', undo: 'Späť', redo: 'Znovu', cut: 'Vystrihnúť', @@ -4688,6 +4708,7 @@ export const translations: Record = { saveAs: 'Αποθήκευση ως', closeFile: 'Κλείσιμο αρχείου', closeWindow: 'Κλείσιμο παραθύρου', + moveToNewWindow: 'Μετακίνηση σε νέο παράθυρο', undo: 'Αναίρεση', redo: 'Επανάληψη', cut: 'Αποκοπή', @@ -4903,6 +4924,7 @@ export const translations: Record = { saveAs: 'Tallenna nimellä', closeFile: 'Sulje tiedosto', closeWindow: 'Sulje ikkuna', + moveToNewWindow: 'Siirrä uuteen ikkunaan', undo: 'Kumoa', redo: 'Tee uudelleen', cut: 'Leikkaa', @@ -5118,6 +5140,7 @@ export const translations: Record = { saveAs: 'Gem som', closeFile: 'Luk fil', closeWindow: 'Luk vindue', + moveToNewWindow: 'Flyt til nyt vindue', undo: 'Fortryd', redo: 'Gentag', cut: 'Klip', @@ -5333,6 +5356,7 @@ export const translations: Record = { saveAs: 'Lagre som', closeFile: 'Lukk fil', closeWindow: 'Lukk vindu', + moveToNewWindow: 'Flytt til nytt vindu', undo: 'Angre', redo: 'Gjør om', cut: 'Klipp ut', @@ -5548,6 +5572,7 @@ export const translations: Record = { saveAs: 'Simpan Sebagai', closeFile: 'Tutup Berkas', closeWindow: 'Tutup Jendela', + moveToNewWindow: 'Pindahkan ke Jendela Baru', undo: 'Urungkan', redo: 'Ulangi', cut: 'Potong', @@ -5763,6 +5788,7 @@ export const translations: Record = { saveAs: 'Farklı Kaydet', closeFile: 'Dosyayı Kapat', closeWindow: 'Pencereyi Kapat', + moveToNewWindow: 'Yeni Pencereye Taşı', undo: 'Geri Al', redo: 'Yinele', cut: 'Kes', diff --git a/src/lib/utils/tabTransfer.ts b/src/lib/utils/tabTransfer.ts new file mode 100644 index 0000000..ac128cb --- /dev/null +++ b/src/lib/utils/tabTransfer.ts @@ -0,0 +1,167 @@ +import type { Tab } from '../stores/tabs.svelte.js'; +import { nextUntitledTitle } from './untitledTitle.js'; + +/** + * Cross-window tab transfer: a tab is snapshotted in the source window, + * carried through the Rust broker as JSON, and rebuilt in the destination. + * + * This snapshot is deliberately INDEPENDENT of TabManager.serializeState() + * (localStorage `savedTabsDataV2`), which persists window shape only and + * never carries content. A transferred tab, by contrast, MUST carry its + * unsaved buffer — rawContent/originalContent/isDirty travel with it, so a + * dirty tab arrives dirty and nothing is lost or silently "cleaned". + * + * Excluded on purpose: + * - `content` (rendered HTML): regenerated at the destination. + * - `editorViewState`: a live Monaco object, not serializable. + */ +export interface TransferableTab { + path: string; + title: string; + rawContent: string; + originalContent: string; + isDirty: boolean; + isEditing: boolean; + isSplit: boolean; + isScrollSynced: boolean; + splitRatio: number; + scrollTop: number; + scrollPercentage: number; + anchorLine: number; + historyIndex: number; + history: string[]; +} + +const STRING_FIELDS = ['path', 'title', 'rawContent', 'originalContent'] as const; +const BOOLEAN_FIELDS = ['isDirty', 'isEditing', 'isSplit', 'isScrollSynced'] as const; +const NUMBER_FIELDS = [ + 'splitRatio', + 'scrollTop', + 'scrollPercentage', + 'anchorLine', + 'historyIndex', +] as const; + +/** Copy the transferable fields of a tab. Never mutates the source. */ +export function snapshotTab(tab: Tab): TransferableTab { + return { + path: tab.path, + title: tab.title, + rawContent: tab.rawContent, + originalContent: tab.originalContent, + isDirty: tab.isDirty, + isEditing: tab.isEditing, + isSplit: tab.isSplit, + isScrollSynced: tab.isScrollSynced, + splitRatio: tab.splitRatio, + scrollTop: tab.scrollTop, + scrollPercentage: tab.scrollPercentage, + anchorLine: tab.anchorLine, + historyIndex: tab.historyIndex, + history: [...tab.history], + }; +} + +/** + * Parse and STRICTLY validate a transfer payload. Every field must have + * exactly the declared type — no coercion, no defaults. A payload that is + * even slightly off is rejected (null) rather than patched up: a past bug + * built tabs whose rawContent was undefined, the editor attributed a stale + * buffer to them, and auto-save then destroyed real files. + */ +export function validateTransferPayload(json: string): TransferableTab | null { + let data: unknown; + try { + data = JSON.parse(json); + } catch { + return null; + } + if (typeof data !== 'object' || data === null || Array.isArray(data)) return null; + const obj = data as Record; + + for (const field of STRING_FIELDS) { + if (typeof obj[field] !== 'string') return null; + } + for (const field of BOOLEAN_FIELDS) { + if (typeof obj[field] !== 'boolean') return null; + } + for (const field of NUMBER_FIELDS) { + const value = obj[field]; + if (typeof value !== 'number' || !Number.isFinite(value)) return null; + } + const history = obj.history; + if (!Array.isArray(history)) return null; + for (const entry of history) { + if (typeof entry !== 'string') return null; + } + + // Rebuild explicitly so unknown extra fields are dropped. + return { + path: obj.path as string, + title: obj.title as string, + rawContent: obj.rawContent as string, + originalContent: obj.originalContent as string, + isDirty: obj.isDirty as boolean, + isEditing: obj.isEditing as boolean, + isSplit: obj.isSplit as boolean, + isScrollSynced: obj.isScrollSynced as boolean, + splitRatio: obj.splitRatio as number, + scrollTop: obj.scrollTop as number, + scrollPercentage: obj.scrollPercentage as number, + anchorLine: obj.anchorLine as number, + historyIndex: obj.historyIndex as number, + history: [...history] as string[], + }; +} + +/** + * Title for a transferred tab in its destination window. The title is the + * document's identity, so movement keeps it whenever possible: an untitled + * tab (path === '') is re-numbered ONLY when the destination already has a + * tab with that exact title — impossible for a fresh detach window, only + * reachable by a future move-to-existing-window. Numbering exists to + * disambiguate a window's own close dialogs; cross-window duplicate titles + * are the status quo for file tabs (two folders' notes.md) and are fine. + */ +export function transferredTabTitle( + snap: TransferableTab, + existingTitles: readonly string[], + untitledBase: string, +): string { + if (snap.path === '' && existingTitles.includes(snap.title)) { + return nextUntitledTitle(existingTitles, untitledBase); + } + return snap.title; +} + +/** + * Build a full Tab for the destination window from a validated snapshot. + * Pure aside from crypto.randomUUID(); TabManager.insertTransferredTab is a + * thin wrapper that pushes the result and activates it. Rendered `content` + * starts empty (the caller re-renders) and editorViewState starts null. + */ +export function buildTransferredTab( + snap: TransferableTab, + existingTitles: readonly string[], + untitledBase: string, +): Tab { + return { + id: crypto.randomUUID(), + path: snap.path, + title: transferredTabTitle(snap, existingTitles, untitledBase), + content: '', + rawContent: snap.rawContent, + originalContent: snap.originalContent, + scrollTop: snap.scrollTop, + isDirty: snap.isDirty, + isEditing: snap.isEditing, + history: [...snap.history], + historyIndex: snap.historyIndex, + editorViewState: null, + scrollPercentage: snap.scrollPercentage, + anchorLine: snap.anchorLine, + isSplit: snap.isSplit, + splitRatio: snap.splitRatio, + isScrollSynced: snap.isScrollSynced, + }; +} From 112343c27fdb2a5e15d389eb590a1942c9fb60b3 Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Mon, 13 Jul 2026 09:52:22 +0900 Subject: [PATCH 12/19] feat: 'Move to New Window' in the tab context menu; window-targeted tab events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/lib/components/Tab.svelte | 25 +++++++++++++++++-------- src/lib/components/TabList.svelte | 7 ++++--- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/lib/components/Tab.svelte b/src/lib/components/Tab.svelte index 4a19ed4..81339a4 100644 --- a/src/lib/components/Tab.svelte +++ b/src/lib/components/Tab.svelte @@ -1,8 +1,9 @@