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
8 changes: 4 additions & 4 deletions scripts/interruptedSessionRestore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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\);/);
Expand Down
4 changes: 2 additions & 2 deletions scripts/tabTransfer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 \}\)/);
});
10 changes: 5 additions & 5 deletions scripts/viewerDisposal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
21 changes: 13 additions & 8 deletions scripts/windowStateRestore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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\)/);
Expand All @@ -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.
Expand All @@ -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}'));
Expand Down
149 changes: 41 additions & 108 deletions src/lib/MarkdownViewer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);

Expand Down Expand Up @@ -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;
Expand All @@ -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() {
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand All @@ -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-<token>") — 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(
Expand Down Expand Up @@ -3151,7 +3084,7 @@ import { createWindowSession } from './sessions/windowSession.svelte.js';
}),
);

if (disposed) {
if (isDisposed) {
unlisteners.forEach((unlisten) => unlisten());
return;
}
Expand All @@ -3163,21 +3096,21 @@ 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) {
console.error('Error receiving Markdown file path:', error);
}
}

if (!disposed) mode = appMode;
if (!isDisposed) mode = appMode;
};

init();

return () => {
disposed = true;
isDisposed = true;
unlisteners.forEach((u) => u());
};
});
Expand Down
Loading
Loading