diff --git a/scripts/interruptedSessionRestore.test.ts b/scripts/interruptedSessionRestore.test.ts index 99c9fed..908b6fb 100644 --- a/scripts/interruptedSessionRestore.test.ts +++ b/scripts/interruptedSessionRestore.test.ts @@ -7,13 +7,13 @@ const session = readFileSync('src/lib/sessions/windowSession.svelte.ts', 'utf8') test('session restore records work in progress before restoring tabs', () => { assert.match(viewer, /const RESTORE_IN_PROGRESS_KEY = 'markpad-window-restore-in-progress';/); - assert.match(viewer, /localStorage\.setItem\(RESTORE_IN_PROGRESS_KEY, 'true'\);/); - assert.match(viewer, /finally \{\s*localStorage\.removeItem\(RESTORE_IN_PROGRESS_KEY\);/s); + assert.match(session, /localStorage\.setItem\(options\.restoreInProgressKey, 'true'\);/); + assert.match(session, /finally \{\s*localStorage\.removeItem\(options\.restoreInProgressKey\);/s); }); test('an interrupted restore discards saved tabs without deleting documents', () => { - assert.match(viewer, /if \(localStorage\.getItem\(RESTORE_IN_PROGRESS_KEY\)\)/); - assert.match(viewer, /await discardPersistedWindowState\(\);/); + assert.match(session, /if \(localStorage\.getItem\(options\.restoreInProgressKey\)\)/); + assert.match(session, /await discardPersistedState\(\);/); assert.match(viewer, /await windowSession\.discardPersistedState\(\);/); assert.match(session, /localStorage\.removeItem\(options\.windowStateKey\);/); assert.match(session, /localStorage\.removeItem\(options\.legacyStateKey\);/); diff --git a/scripts/tabTransfer.test.ts b/scripts/tabTransfer.test.ts index cafa313..15062b8 100644 --- a/scripts/tabTransfer.test.ts +++ b/scripts/tabTransfer.test.ts @@ -220,10 +220,10 @@ test('serializeState still persists window shape only (untouched by transfer)', test('source removal waits for destination completion', () => { const broker = readFileSync('src-tauri/src/tab_transfer.rs', 'utf8'); - const viewer = readFileSync('src/lib/MarkdownViewer.svelte', 'utf8'); + const session = readFileSync('src/lib/sessions/windowSession.svelte.ts', 'utf8'); assert.match(broker, /pub fn complete_detached_tab/); const claim = broker.slice(broker.indexOf('pub fn claim_detached_tab'), broker.indexOf('pub fn complete_detached_tab')); assert.doesNotMatch(claim, /tab-transfer-claimed/); - assert.match(viewer, /invoke\('complete_detached_tab', \{ token: claimToken \}\)/); + assert.match(session, /invoke\('complete_detached_tab', \{ token: claimToken \}\)/); }); diff --git a/scripts/viewerDisposal.test.ts b/scripts/viewerDisposal.test.ts index 0a65840..ca0a19e 100644 --- a/scripts/viewerDisposal.test.ts +++ b/scripts/viewerDisposal.test.ts @@ -5,12 +5,12 @@ import test from 'node:test'; const viewer = readFileSync('src/lib/MarkdownViewer.svelte', 'utf8'); test('onMount initialization tracks and checks disposal around async work', () => { - assert.match(viewer, /let disposed = false;/); - assert.match(viewer, /if \(disposed\) return;/); - assert.match(viewer, /if \(!disposed && args\?\.length > 0\)/); - assert.match(viewer, /disposed = true;/); + assert.match(viewer, /let isDisposed = false;/); + assert.match(viewer, /if \(isDisposed\) return;/); + assert.match(viewer, /if \(!isDisposed && args\?\.length > 0\)/); + assert.match(viewer, /isDisposed = true;/); }); test('listeners registered after disposal are released', () => { - assert.match(viewer, /if \(disposed\) \{\s*unlisteners\.forEach\(\(unlisten\) => unlisten\(\)\);/s); + assert.match(viewer, /if \(isDisposed\) \{\s*unlisteners\.forEach\(\(unlisten\) => unlisten\(\)\);/s); }); diff --git a/scripts/windowStateRestore.test.ts b/scripts/windowStateRestore.test.ts index 671a428..edc0b7d 100644 --- a/scripts/windowStateRestore.test.ts +++ b/scripts/windowStateRestore.test.ts @@ -44,10 +44,11 @@ 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(WINDOW_STATE_KEY)', 'urlParams'); - assert.match(init, /read_file_content/); + const restore = slice(session, 'async function restore', 'async function claimTransferredTab'); + assert.match(restore, /read_file_content/); // a missing file drops its tab instead of restoring a ghost - assert.match(init, /closeTab\(/); + assert.match(restore, /options\.dropRestoredTab\(tab\.id\);/); + assert.match(viewer, /await windowSession\.restore\(\);/); }); test('the discard choice reverts the tab to its last saved content', () => { @@ -74,7 +75,8 @@ test('v2 snapshots are invisible to legacy builds (Rust file, localStorage keys // process and loses a flush race when the last window's close ends the // process); both localStorage keys are removed on write, so a downgraded // build starts a fresh session instead of misreading anything. - const scope = session.slice(session.indexOf('async function persistState')); + const persistStart = session.indexOf('async function persistState'); + const scope = session.slice(persistStart, session.indexOf('async function restore', persistStart)); assert.match(scope, /invoke\('save_window_state'/); assert.doesNotMatch(scope, /setItem\(/); assert.match(scope, /removeItem\(options\.windowStateKey\)/); @@ -85,10 +87,10 @@ test('v2 snapshots are invisible to legacy builds (Rust file, localStorage keys assert.match(scope, /if \(!options\.isMainWindow\) return;/); // startup prefers the Rust file and falls back to the localStorage keys // (v2 first, then legacy) for one-time migration of older snapshots - assert.match(viewer, /invoke\('load_window_state'\)/); + assert.match(session, /invoke\('load_window_state'\)/); assert.match( - viewer, - /localStorage\.getItem\(WINDOW_STATE_KEY\) \?\?\n?\s*localStorage\.getItem\(LEGACY_STATE_KEY\)/, + session, + /localStorage\.getItem\(options\.windowStateKey\) \?\?\n?\s*localStorage\.getItem\(options\.legacyStateKey\)/, ); // The shared helper clears the Rust snapshot and both localStorage keys; // explicit exit and interrupted restore both use this same cleanup path. @@ -97,7 +99,10 @@ test('v2 snapshots are invisible to legacy builds (Rust file, localStorage keys assert.match(discardScope, /clear_window_state/); assert.match(discardScope, /removeItem\(options\.windowStateKey\)/); assert.match(discardScope, /removeItem\(options\.legacyStateKey\)/); - assert.match(viewer, /if \(localStorage\.getItem\(RESTORE_IN_PROGRESS_KEY\)\)[\s\S]*?await discardPersistedWindowState\(\)/); + assert.match( + session, + /if \(localStorage\.getItem\(options\.restoreInProgressKey\)\)[\s\S]*?await discardPersistedState\(\)/, + ); // Explicit exit delegates to the same cleanup path. const exitFn = viewer.slice(viewer.indexOf('async function appExit')); const exitScope = exitFn.slice(0, exitFn.indexOf('\n\t}')); diff --git a/src/lib/MarkdownViewer.svelte b/src/lib/MarkdownViewer.svelte index f7be27f..10712bd 100644 --- a/src/lib/MarkdownViewer.svelte +++ b/src/lib/MarkdownViewer.svelte @@ -49,7 +49,7 @@ import { import DOMPurify from 'dompurify'; import HomePage from './components/HomePage.svelte'; import { tabManager } from './stores/tabs.svelte.js'; -import { snapshotTab, validateTransferPayload } from './utils/tabTransfer.js'; +import { snapshotTab } from './utils/tabTransfer.js'; import { settings } from './stores/settings.svelte.js'; import { t } from './utils/i18n.js'; import { createWindowSession } from './sessions/windowSession.svelte.js'; @@ -64,6 +64,7 @@ import { createWindowSession } from './sessions/windowSession.svelte.js'; import 'katex/dist/katex.min.css'; let mode = $state<'loading' | 'app' | 'installer' | 'uninstall'>('loading'); + let isDisposed = false; let showSettings = $state(false); @@ -425,7 +426,23 @@ import { createWindowSession } from './sessions/windowSession.svelte.js'; isMainWindow, windowStateKey: WINDOW_STATE_KEY, legacyStateKey: LEGACY_STATE_KEY, + restoreInProgressKey: RESTORE_IN_PROGRESS_KEY, serializeState: () => tabManager.serializeState(), + shouldRestoreState: () => settings.restoreStateOnReopen, + isDisposed: () => isDisposed, + restoreState: (json) => tabManager.restoreState(json), + restoredTabs: () => tabManager.tabs.map((tab) => ({ id: tab.id, path: tab.path })), + applyRestoredContent: async (tabId, raw) => { + const tab = tabManager.tabs.find((item) => item.id === tabId); + if (!tab) return; + tab.rawContent = raw; + tab.originalContent = raw; + const processed = await renderMarkdownPreview(raw, tab.path); + if (isDisposed) return; + tabManager.updateTabContent(tab.id, processed); + if (tabManager.activeTabId === tab.id) tick().then(renderRichContent); + }, + dropRestoredTab: (tabId) => tabManager.closeTab(tabId), canDetach: (tabId) => { const tab = tabManager.tabs.find((item) => item.id === tabId); return !isCloseWalkActive && tab !== undefined && tab.path !== 'HOME' && tabManager.tabs.length >= 2; @@ -436,10 +453,18 @@ import { createWindowSession } from './sessions/windowSession.svelte.js'; return JSON.stringify(snapshotTab(tab)); }, onTransferClaimed: (tabId) => tabManager.closeTab(tabId), + acceptTransferredTab: async (snapshot) => { + const id = tabManager.insertTransferredTab(snapshot); + const transferred = tabManager.tabs.find((tab) => tab.id === id); + if (!transferred) return false; + await renderTabPreviewFromRaw(transferred); + return !isDisposed; + }, onError: (message, error) => { console.error(message, error); addToast(`${message}: ${String(error)}`, 'error'); }, + onWarning: (message, error) => console.warn(message, error), }); async function discardPersistedWindowState() { @@ -2732,11 +2757,11 @@ import { createWindowSession } from './sessions/windowSession.svelte.js'; onMount(() => { loadRecentFiles(); - let disposed = false; + isDisposed = false; // @ts-ignore Promise.all([import('highlight.js'), import('highlightjs-svelte'), import('katex'), import('mermaid')]).then(async ([hljsModule, svelteModule, katexMainModule, mermaidModule]) => { - if (disposed) return; + if (isDisposed) return; hljs = hljsModule.default; try { svelteModule.default(hljs); @@ -2752,7 +2777,7 @@ import { createWindowSession } from './sessions/windowSession.svelte.js'; import('katex/dist/contrib/mhchem.js'), import('katex/dist/contrib/copy-tex.js') ]); - if (disposed) return; + if (isDisposed) return; renderMathInElement = autoRenderModule.default; mermaid = mermaidModule.default; @@ -2765,113 +2790,21 @@ import { createWindowSession } from './sessions/windowSession.svelte.js'; const init = async () => { const appWindow = getCurrentWindow(); const appMode = (await invoke('get_app_mode')) as any; - if (disposed) return; - - if (isMainWindow && settings.restoreStateOnReopen) { - // localStorage first, Rust file as fallback. Startup always - // deletes the localStorage keys after migrating, so their - // presence means an OLDER build wrote them since our last - // run (fresh install upgrade, or a downgrade period) — in - // either case they are newer than the Rust file. Reading the - // file first would resurrect a pre-downgrade snapshot over - // the one the older build just wrote. - const savedData = - localStorage.getItem(WINDOW_STATE_KEY) ?? - localStorage.getItem(LEGACY_STATE_KEY) ?? - ((await invoke('load_window_state').catch(() => null)) as string | null); - if (localStorage.getItem(RESTORE_IN_PROGRESS_KEY)) { - // A previous startup was interrupted while restoring its tabs. - // Drop only the snapshot so an unprocessable document cannot - // make every subsequent launch unusable. - console.warn('Skipping interrupted Markpad session restore'); - await discardPersistedWindowState(); - localStorage.removeItem(RESTORE_IN_PROGRESS_KEY); - } else if (savedData) { - localStorage.setItem(RESTORE_IN_PROGRESS_KEY, 'true'); - try { - tabManager.restoreState(savedData); - // 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; - if (disposed) return; - tab.rawContent = raw; - tab.originalContent = raw; - const processed = await renderMarkdownPreview(raw, tab.path); - if (disposed) return; - tabManager.updateTabContent(tab.id, processed); - if (tabManager.activeTabId === tab.id) { - tick().then(renderRichContent); - } - } catch (e) { - if (disposed) return; - console.warn('Restore: dropping tab for unreadable file', tab.path, e); - tabManager.closeTab(tab.id); - } - } - } catch (e) { - console.error('Failed to restore Markpad session:', e); - await discardPersistedWindowState(); - } finally { - localStorage.removeItem(RESTORE_IN_PROGRESS_KEY); - } - } - } - if (isMainWindow) { - // Hand the snapshot over to the Rust file NOW, then drop the - // localStorage keys: write-through first so a crash between - // the two steps can never lose the snapshot, and delete at - // startup rather than at close so the stale copy cannot - // outlive the migration (the close-time removal never runs - // for users who disabled restore-on-reopen, which would - // leave the old snapshot on disk forever). - if (settings.restoreStateOnReopen && tabManager.tabs.length > 0) { - await persistWindowState(); - } - localStorage.removeItem(WINDOW_STATE_KEY); - localStorage.removeItem(LEGACY_STATE_KEY); - } + if (isDisposed) return; - const urlParams = new URLSearchParams(window.location.search); + await windowSession.restore(); + if (isDisposed) return; + await windowSession.claimTransferredTab(); + if (isDisposed) return; - // A window created by "Move to New Window" claims its tab from the - // Rust broker. The transfer token rides in the window label itself - // ("window-") — a URL query would 404 in the asset protocol. - // The payload is validated strictly before any tab is built: a tab - // whose content fields are not strings must never be constructed - // (the editor would attribute a stale buffer to it and auto-save - // could destroy the file). A failed claim or invalid payload just - // yields an empty window — the source kept its tab. - const claimToken = appWindow.label.startsWith('window-') - ? appWindow.label.slice('window-'.length) - : null; - if (claimToken) { - try { - const payload = (await invoke('claim_detached_tab', { token: claimToken })) as string | null; - const snap = payload ? validateTransferPayload(payload) : null; - if (snap) { - const id = tabManager.insertTransferredTab(snap); - const transferred = tabManager.tabs.find((t) => t.id === id); - if (transferred) { - await renderTabPreviewFromRaw(transferred); - await invoke('complete_detached_tab', { token: claimToken }); - } - } else { - console.warn('Tab transfer claim failed or payload invalid; opening empty window'); - } - } catch (e) { - console.error('Tab transfer claim error:', e); - } - } + const urlParams = new URLSearchParams(window.location.search); const fileParam = urlParams.get('file'); if (fileParam) { const decodedPath = decodeURIComponent(fileParam); - if (disposed) return; + if (isDisposed) return; await loadMarkdown(decodedPath); - if (disposed) return; + if (isDisposed) return; } unlisteners.push( @@ -3151,7 +3084,7 @@ import { createWindowSession } from './sessions/windowSession.svelte.js'; }), ); - if (disposed) { + if (isDisposed) { unlisteners.forEach((unlisten) => unlisten()); return; } @@ -3163,7 +3096,7 @@ import { createWindowSession } from './sessions/windowSession.svelte.js'; if (isMainWindow) { try { const args: string[] = await invoke('send_markdown_path'); - if (!disposed && args?.length > 0) { + if (!isDisposed && args?.length > 0) { await loadMarkdown(args[0]); } } catch (error) { @@ -3171,13 +3104,13 @@ import { createWindowSession } from './sessions/windowSession.svelte.js'; } } - if (!disposed) mode = appMode; + if (!isDisposed) mode = appMode; }; init(); return () => { - disposed = true; + isDisposed = true; unlisteners.forEach((u) => u()); }; }); diff --git a/src/lib/sessions/windowSession.svelte.ts b/src/lib/sessions/windowSession.svelte.ts index fb534c5..175556e 100644 --- a/src/lib/sessions/windowSession.svelte.ts +++ b/src/lib/sessions/windowSession.svelte.ts @@ -1,15 +1,30 @@ import { invoke } from '@tauri-apps/api/core'; import { getCurrentWindow } from '@tauri-apps/api/window'; +import { validateTransferPayload, type TransferableTab } from '../utils/tabTransfer.js'; + +type RestoredTab = { + id: string; + path: string; +}; type WindowSessionOptions = { isMainWindow: boolean; windowStateKey: string; legacyStateKey: string; + restoreInProgressKey: string; serializeState: () => string; + shouldRestoreState: () => boolean; + isDisposed: () => boolean; + restoreState: (json: string) => void; + restoredTabs: () => RestoredTab[]; + applyRestoredContent: (tabId: string, raw: string) => Promise; + dropRestoredTab: (tabId: string) => void; canDetach: (tabId: string) => boolean; transferPayload: (tabId: string) => string; onTransferClaimed: (tabId: string) => void; + acceptTransferredTab: (tab: TransferableTab) => Promise; onError: (message: string, error: unknown) => void; + onWarning: (message: string, error?: unknown) => void; }; export function createWindowSession(options: WindowSessionOptions) { @@ -37,6 +52,72 @@ export function createWindowSession(options: WindowSessionOptions) { } } + async function restore() { + if (!options.isMainWindow || options.isDisposed()) return; + if (!options.shouldRestoreState()) { + localStorage.removeItem(options.windowStateKey); + localStorage.removeItem(options.legacyStateKey); + return; + } + const savedData = + localStorage.getItem(options.windowStateKey) ?? + localStorage.getItem(options.legacyStateKey) ?? + ((await invoke('load_window_state').catch(() => null)) as string | null); + if (localStorage.getItem(options.restoreInProgressKey)) { + options.onWarning('Skipping interrupted Markpad session restore'); + await discardPersistedState(); + localStorage.removeItem(options.restoreInProgressKey); + return; + } + if (savedData) { + localStorage.setItem(options.restoreInProgressKey, 'true'); + try { + options.restoreState(savedData); + for (const tab of options.restoredTabs()) { + try { + const raw = (await invoke('read_file_content', { path: tab.path })) as string; + if (options.isDisposed()) return; + await options.applyRestoredContent(tab.id, raw); + if (options.isDisposed()) return; + } catch (error) { + if (options.isDisposed()) return; + options.onWarning('Restore: dropping tab for unreadable file', error); + options.dropRestoredTab(tab.id); + } + } + } catch (error) { + options.onError('Failed to restore Markpad session', error); + await discardPersistedState(); + } finally { + localStorage.removeItem(options.restoreInProgressKey); + } + } + if (options.isDisposed()) return; + if (options.restoredTabs().length > 0) await persistState(); + localStorage.removeItem(options.windowStateKey); + localStorage.removeItem(options.legacyStateKey); + } + + async function claimTransferredTab() { + const claimToken = appWindow.label.startsWith('window-') + ? appWindow.label.slice('window-'.length) + : null; + if (!claimToken) return; + try { + const payload = (await invoke('claim_detached_tab', { token: claimToken })) as string | null; + const tab = payload ? validateTransferPayload(payload) : null; + if (!tab) { + options.onWarning('Tab transfer claim failed or payload invalid; opening empty window'); + return; + } + if (await options.acceptTransferredTab(tab)) { + await invoke('complete_detached_tab', { token: claimToken }); + } + } catch (error) { + options.onError('Tab transfer claim error', error); + } + } + async function detach(tabId: string) { if (!options.canDetach(tabId)) return; const token = (await invoke('stage_detached_tab', { @@ -66,5 +147,5 @@ export function createWindowSession(options: WindowSessionOptions) { } } - return { discardPersistedState, persistState, detach }; + return { discardPersistedState, persistState, restore, claimTransferredTab, detach }; }