Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions scripts/interruptedSessionRestore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';/);
Expand All @@ -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'\);/);
});
18 changes: 9 additions & 9 deletions scripts/windowStateRestore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'\)/);
Expand All @@ -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'));
Expand Down
69 changes: 24 additions & 45 deletions src/lib/MarkdownViewer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand All @@ -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() {
Expand Down Expand Up @@ -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<string>('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) {
Expand Down
70 changes: 70 additions & 0 deletions src/lib/sessions/windowSession.svelte.ts
Original file line number Diff line number Diff line change
@@ -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<string>('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 };
}
Loading