diff --git a/scripts/interruptedSessionRestore.test.ts b/scripts/interruptedSessionRestore.test.ts index ce795f2..99c9fed 100644 --- a/scripts/interruptedSessionRestore.test.ts +++ b/scripts/interruptedSessionRestore.test.ts @@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs'; import test from 'node:test'; const viewer = readFileSync('src/lib/MarkdownViewer.svelte', 'utf8'); +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';/); @@ -13,6 +14,8 @@ test('session restore records work in progress before restoring tabs', () => { 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(viewer, /async function discardPersistedWindowState\(\) \{\s*localStorage\.removeItem\(WINDOW_STATE_KEY\);\s*localStorage\.removeItem\(LEGACY_STATE_KEY\);/s); - assert.match(viewer, /await invoke\('clear_window_state'\);/); + assert.match(viewer, /await windowSession\.discardPersistedState\(\);/); + assert.match(session, /localStorage\.removeItem\(options\.windowStateKey\);/); + assert.match(session, /localStorage\.removeItem\(options\.legacyStateKey\);/); + assert.match(session, /await invoke\('clear_window_state'\);/); }); diff --git a/scripts/windowStateRestore.test.ts b/scripts/windowStateRestore.test.ts index 471cd9b..671a428 100644 --- a/scripts/windowStateRestore.test.ts +++ b/scripts/windowStateRestore.test.ts @@ -4,6 +4,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'); // Session restore persists WINDOW state only: which files are open, the // active tab, and per-tab UI (edit mode, split, scroll). Document content @@ -73,16 +74,15 @@ 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 helper = viewer.slice(viewer.indexOf('async function persistWindowState')); - const scope = helper.slice(0, helper.indexOf('\n\t}')); + const scope = session.slice(session.indexOf('async function persistState')); assert.match(scope, /invoke\('save_window_state'/); assert.doesNotMatch(scope, /setItem\(/); - assert.match(scope, /removeItem\(WINDOW_STATE_KEY\)/); - assert.match(scope, /removeItem\(LEGACY_STATE_KEY\)/); + assert.match(scope, /removeItem\(options\.windowStateKey\)/); + assert.match(scope, /removeItem\(options\.legacyStateKey\)/); assert.match(viewer, /const WINDOW_STATE_KEY = 'savedTabsDataV2';/); // only the main window persists: secondary labels are per-session, and a // shared write slot would let the last window closed overwrite the rest - assert.match(scope, /if \(!isMainWindow\) return;/); + 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'\)/); @@ -92,11 +92,11 @@ test('v2 snapshots are invisible to legacy builds (Rust file, localStorage keys ); // The shared helper clears the Rust snapshot and both localStorage keys; // explicit exit and interrupted restore both use this same cleanup path. - const discardStart = viewer.indexOf('async function discardPersistedWindowState'); - const discardScope = viewer.slice(discardStart, viewer.indexOf('\n\t}\n\n\t// Persisted', discardStart)); + const discardStart = session.indexOf('async function discardPersistedState'); + const discardScope = session.slice(discardStart, session.indexOf('\n\t}\n\n\tasync function persistState', discardStart)); assert.match(discardScope, /clear_window_state/); - assert.match(discardScope, /removeItem\(WINDOW_STATE_KEY\)/); - assert.match(discardScope, /removeItem\(LEGACY_STATE_KEY\)/); + 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\(\)/); // Explicit exit delegates to the same cleanup path. const exitFn = viewer.slice(viewer.indexOf('async function appExit')); diff --git a/src/lib/MarkdownViewer.svelte b/src/lib/MarkdownViewer.svelte index 8bccde3..f7be27f 100644 --- a/src/lib/MarkdownViewer.svelte +++ b/src/lib/MarkdownViewer.svelte @@ -52,6 +52,7 @@ import { tabManager } from './stores/tabs.svelte.js'; import { snapshotTab, validateTransferPayload } from './utils/tabTransfer.js'; import { settings } from './stores/settings.svelte.js'; import { t } from './utils/i18n.js'; +import { createWindowSession } from './sessions/windowSession.svelte.js'; // syntax highlighting & latex let hljs: any = $state(null); @@ -420,16 +421,29 @@ import { t } from './utils/i18n.js'; // be restored under the same label again, and letting N windows write the // shared key means the last window closed overwrites everyone else. const isMainWindow = appWindow.label === 'main'; + const windowSession = createWindowSession({ + isMainWindow, + windowStateKey: WINDOW_STATE_KEY, + legacyStateKey: LEGACY_STATE_KEY, + serializeState: () => tabManager.serializeState(), + canDetach: (tabId) => { + const tab = tabManager.tabs.find((item) => item.id === tabId); + return !isCloseWalkActive && tab !== undefined && tab.path !== 'HOME' && tabManager.tabs.length >= 2; + }, + transferPayload: (tabId) => { + const tab = tabManager.tabs.find((item) => item.id === tabId); + if (!tab) throw new Error('Tab disappeared before transfer'); + return JSON.stringify(snapshotTab(tab)); + }, + onTransferClaimed: (tabId) => tabManager.closeTab(tabId), + onError: (message, error) => { + console.error(message, error); + addToast(`${message}: ${String(error)}`, 'error'); + }, + }); async function discardPersistedWindowState() { - localStorage.removeItem(WINDOW_STATE_KEY); - localStorage.removeItem(LEGACY_STATE_KEY); - if (!isMainWindow) return; - try { - await invoke('clear_window_state'); - } catch (e) { - console.error('Failed to clear window state:', e); - } + await windowSession.discardPersistedState(); } // Persisted through Rust, not localStorage: setItem is an async message @@ -442,14 +456,7 @@ import { t } from './utils/i18n.js'; // after the first successful Rust write; a downgraded build then starts // a fresh session instead of misreading anything. async function persistWindowState() { - if (!isMainWindow) return; - try { - await invoke('save_window_state', { json: tabManager.serializeState() }); - localStorage.removeItem(WINDOW_STATE_KEY); - localStorage.removeItem(LEGACY_STATE_KEY); - } catch (e) { - console.error('Failed to save state on close:', e); - } + await windowSession.persistState(); } async function appExit() { @@ -2634,35 +2641,7 @@ import { t } from './utils/i18n.js'; // deleted only after the destination confirms the claim, so any failure — // window creation error, timeout — leaves the tab exactly where it was. async function handleDetach(tabId: string) { - if (isCloseWalkActive) return; - const tab = tabManager.tabs.find((t) => t.id === tabId); - if (!tab || tab.path === 'HOME' || tabManager.tabs.length < 2) return; - - const payload = JSON.stringify(snapshotTab(tab)); - const token = (await invoke('stage_detached_tab', { payload })) as string; - - let settled = false; - const unlisten = await appWindow.listen('tab-transfer-claimed', (event) => { - if (settled || event.payload !== token) return; - settled = true; - unlisten(); - tabManager.closeTab(tabId); - }); - const cancel = () => { - if (settled) return; - settled = true; - unlisten(); - invoke('cancel_detached_tab', { token }).catch(console.error); - }; - setTimeout(cancel, 15000); - - try { - await invoke('create_transfer_window', { token }); - } catch (e) { - console.error('create_transfer_window failed:', e); - addToast('Failed to open new window: ' + String(e), 'error'); - cancel(); - } + await windowSession.detach(tabId); } function startDrag(e: MouseEvent, tabId: string | null) { diff --git a/src/lib/sessions/windowSession.svelte.ts b/src/lib/sessions/windowSession.svelte.ts new file mode 100644 index 0000000..fb534c5 --- /dev/null +++ b/src/lib/sessions/windowSession.svelte.ts @@ -0,0 +1,70 @@ +import { invoke } from '@tauri-apps/api/core'; +import { getCurrentWindow } from '@tauri-apps/api/window'; + +type WindowSessionOptions = { + isMainWindow: boolean; + windowStateKey: string; + legacyStateKey: string; + serializeState: () => string; + canDetach: (tabId: string) => boolean; + transferPayload: (tabId: string) => string; + onTransferClaimed: (tabId: string) => void; + onError: (message: string, error: unknown) => void; +}; + +export function createWindowSession(options: WindowSessionOptions) { + const appWindow = getCurrentWindow(); + + async function discardPersistedState() { + localStorage.removeItem(options.windowStateKey); + localStorage.removeItem(options.legacyStateKey); + if (!options.isMainWindow) return; + try { + await invoke('clear_window_state'); + } catch (error) { + options.onError('Failed to clear window state', error); + } + } + + async function persistState() { + if (!options.isMainWindow) return; + try { + await invoke('save_window_state', { json: options.serializeState() }); + localStorage.removeItem(options.windowStateKey); + localStorage.removeItem(options.legacyStateKey); + } catch (error) { + options.onError('Failed to save window state on close', error); + } + } + + async function detach(tabId: string) { + if (!options.canDetach(tabId)) return; + const token = (await invoke('stage_detached_tab', { + payload: options.transferPayload(tabId), + })) as string; + let settled = false; + const unlisten = await appWindow.listen('tab-transfer-claimed', (event) => { + if (settled || event.payload !== token) return; + settled = true; + unlisten(); + options.onTransferClaimed(tabId); + }); + const cancel = () => { + if (settled) return; + settled = true; + unlisten(); + invoke('cancel_detached_tab', { token }).catch((error) => { + options.onError('Failed to cancel tab transfer', error); + }); + }; + setTimeout(cancel, 15_000); + try { + await invoke('create_transfer_window', { token }); + } catch (error) { + options.onError('Failed to open new window', error); + cancel(); + } + } + + return { discardPersistedState, persistState, detach }; +}