From cd277befc6320027c01dc2dc248f1b86b11b2dc9 Mon Sep 17 00:00:00 2001 From: PathGao Date: Thu, 30 Jul 2026 12:14:37 +0800 Subject: [PATCH] refactor: move close decisions into document session --- scripts/windowStateRestore.test.ts | 3 +- src/lib/MarkdownViewer.svelte | 61 ++++------------------ src/lib/sessions/documentSession.svelte.ts | 29 +++++++++- 3 files changed, 40 insertions(+), 53 deletions(-) diff --git a/scripts/windowStateRestore.test.ts b/scripts/windowStateRestore.test.ts index edc0b7d..b273c2f 100644 --- a/scripts/windowStateRestore.test.ts +++ b/scripts/windowStateRestore.test.ts @@ -5,6 +5,7 @@ import test from 'node:test'; const tabs = readFileSync('src/lib/stores/tabs.svelte.ts', 'utf8'); const viewer = readFileSync('src/lib/MarkdownViewer.svelte', 'utf8'); const session = readFileSync('src/lib/sessions/windowSession.svelte.ts', 'utf8'); +const documentSession = readFileSync('src/lib/sessions/documentSession.svelte.ts', 'utf8'); // Session restore persists WINDOW state only: which files are open, the // active tab, and per-tab UI (edit mode, split, scroll). Document content @@ -52,7 +53,7 @@ test('startup restore reads content from disk, not from the snapshot', () => { }); test('the discard choice reverts the tab to its last saved content', () => { - const fn = slice(viewer, 'async function canCloseTab', 'async function toggleEdit'); + const fn = slice(documentSession, 'async function canCloseTab', 'return { loadMarkdown'); assert.match(fn, /tab\.rawContent = tab\.originalContent;/); assert.match(fn, /tab\.isDirty = false;/); }); diff --git a/src/lib/MarkdownViewer.svelte b/src/lib/MarkdownViewer.svelte index f34c578..ed605e5 100644 --- a/src/lib/MarkdownViewer.svelte +++ b/src/lib/MarkdownViewer.svelte @@ -492,6 +492,15 @@ import { createDocumentSession, type LoadMarkdownOptions } from './sessions/docu addToast(`${message}: ${String(error)}`, 'error'); }, selfWriteGraceMs: SELF_WRITE_GRACE_MS, + cancelPendingAutoSave, + askClose: (title) => + askCustom(t('modal.youHaveUnsavedChanges', settings.language).replace('{title}', title), { + title: t('modal.unsavedChanges', settings.language), + kind: 'warning', + showSave: true, + }), + onCloseSaveNewerEdits: () => addToast(t('toast.savedNewerEdits', settings.language), 'info'), + onCloseAutoSaveFailed: () => addToast(t('toast.autoSaveFailed', settings.language), 'error'), }); async function discardPersistedWindowState() { @@ -1533,57 +1542,7 @@ import { createDocumentSession, type LoadMarkdownOptions } from './sessions/docu } async function canCloseTab(tabId: string): Promise { - const tab = tabManager.tabs.find((t) => t.id === tabId); - if (!tab || (!tab.isDirty && tab.path !== '')) return true; - - if (!tab.isDirty) return true; - - // Silent save path: only when auto-save is on, the user did NOT ask - // for confirmation, and the tab has a real path. Untitled tabs always - // need a save dialog, which means the modal flow is the right place - // for them. We cancel the pending timer right before the manual save - // to avoid a duplicate write from a timer that fires concurrently. - if (settings.autoSave && !settings.confirmBeforeSave && tab.path !== '') { - cancelPendingAutoSave(tabId); - const success = await saveContent(tabId); - // Only allow the close if the tab is fully clean afterwards. - // `saveContent` resolves true even when post-save `isDirty=true` - // (the user typed during the await — TOCTOU) — closing here - // would silently drop those new keystrokes. - if (success && !tab.isDirty) return true; - if (success) { - // Save succeeded but the tab is dirty again — let the user - // decide via the modal whether to save again, discard, or cancel. - addToast(t('toast.savedNewerEdits', settings.language), 'info'); - } else { - // Silent save failed — surface and fall through to the modal. - addToast(t('toast.autoSaveFailed', settings.language), 'error'); - } - } - - const response = await askCustom(t('modal.youHaveUnsavedChanges', settings.language).replace('{title}', tab.title), { - title: t('modal.unsavedChanges', settings.language), - kind: 'warning', - showSave: true, - }); - - // Important: do NOT cancel the pending auto-save timer before this - // modal. If the user clicks Cancel, the tab remains dirty and we - // want background auto-save to keep firing on the existing schedule. - if (response === 'cancel') return false; - if (response === 'save') { - cancelPendingAutoSave(tabId); - return await saveContent(tabId); - } - - // Discard: drop pending save so we don't write what the user just - // 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; + return documentSession.canCloseTab(tabId); } async function toggleEdit(silentSave = false) { diff --git a/src/lib/sessions/documentSession.svelte.ts b/src/lib/sessions/documentSession.svelte.ts index b89172b..4817f09 100644 --- a/src/lib/sessions/documentSession.svelte.ts +++ b/src/lib/sessions/documentSession.svelte.ts @@ -26,6 +26,10 @@ type DocumentSessionOptions = { renderRichContent: () => void; onError: (message: string, error: unknown) => void; selfWriteGraceMs: number; + cancelPendingAutoSave: (tabId: string) => void; + askClose: (title: string) => Promise<'save' | 'discard' | 'cancel'>; + onCloseSaveNewerEdits: () => void; + onCloseAutoSaveFailed: () => void; }; export function createDocumentSession(options: DocumentSessionOptions) { @@ -223,5 +227,28 @@ export function createDocumentSession(options: DocumentSessionOptions) { return true; } - return { loadMarkdown, saveContent, saveContentAs, toggleTaskCheckbox, shouldReloadExternalChange }; + async function canCloseTab(tabId: string): Promise { + const tab = tabManager.tabs.find((item) => item.id === tabId); + if (!tab || (!tab.isDirty && tab.path !== '')) return true; + if (!tab.isDirty) return true; + if (settings.autoSave && !settings.confirmBeforeSave && tab.path !== '') { + options.cancelPendingAutoSave(tabId); + const success = await saveContent(tabId); + if (success && !tab.isDirty) return true; + if (success) options.onCloseSaveNewerEdits(); + else options.onCloseAutoSaveFailed(); + } + const response = await options.askClose(tab.title); + if (response === 'cancel') return false; + if (response === 'save') { + options.cancelPendingAutoSave(tabId); + return saveContent(tabId); + } + options.cancelPendingAutoSave(tabId); + tab.rawContent = tab.originalContent; + tab.isDirty = false; + return true; + } + + return { loadMarkdown, saveContent, saveContentAs, toggleTaskCheckbox, shouldReloadExternalChange, canCloseTab }; }