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
18 changes: 18 additions & 0 deletions scripts/interruptedSessionRestore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';

const viewer = readFileSync('src/lib/MarkdownViewer.svelte', '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);
});

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'\);/);
});
17 changes: 17 additions & 0 deletions scripts/previewRenderRevision.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';

const viewer = readFileSync('src/lib/MarkdownViewer.svelte', 'utf8');

test('preview rendering uses a revision and cleans up its debounce timer', () => {
assert.match(viewer, /let previewRenderRevision = 0;/);
assert.match(viewer, /const renderRevision = \+\+previewRenderRevision;/);
assert.match(viewer, /return \(\) => clearTimeout\(timer\);/);
});

test('a completed preview render verifies its tab, content, and revision', () => {
assert.match(viewer, /previewRenderRevision !== renderRevision/);
assert.match(viewer, /tabManager\.activeTabId !== tabId/);
assert.match(viewer, /currentTab\?\.rawContent !== rawContent/);
});
16 changes: 16 additions & 0 deletions scripts/viewerDisposal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
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;/);
});

test('listeners registered after disposal are released', () => {
assert.match(viewer, /if \(disposed\) \{\s*unlisteners\.forEach\(\(unlisten\) => unlisten\(\)\);/s);
});
14 changes: 10 additions & 4 deletions scripts/windowStateRestore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,12 +90,18 @@ test('v2 snapshots are invisible to legacy builds (Rust file, localStorage keys
viewer,
/localStorage\.getItem\(WINDOW_STATE_KEY\) \?\?\n?\s*localStorage\.getItem\(LEGACY_STATE_KEY\)/,
);
// explicit exit clears the file and both 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));
assert.match(discardScope, /clear_window_state/);
assert.match(discardScope, /removeItem\(WINDOW_STATE_KEY\)/);
assert.match(discardScope, /removeItem\(LEGACY_STATE_KEY\)/);
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'));
const exitScope = exitFn.slice(0, exitFn.indexOf('\n\t}'));
assert.match(exitScope, /clear_window_state/);
assert.match(exitScope, /removeItem\(WINDOW_STATE_KEY\)/);
assert.match(exitScope, /removeItem\(LEGACY_STATE_KEY\)/);
assert.match(exitScope, /await discardPersistedWindowState\(\)/);
});

test('with restore enabled resolved titled tabs stay open for the snapshot', () => {
Expand Down
90 changes: 70 additions & 20 deletions src/lib/MarkdownViewer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,7 @@ import { t } from './utils/i18n.js';
// makes old and new builds invisible to each other.
const WINDOW_STATE_KEY = 'savedTabsDataV2';
const LEGACY_STATE_KEY = 'savedTabsData';
const RESTORE_IN_PROGRESS_KEY = 'markpad-window-restore-in-progress';

// localStorage is origin-scoped, so every window shares the one snapshot
// slot. Only the main window persists and restores tabs: secondary window
Expand All @@ -420,6 +421,17 @@ import { t } from './utils/i18n.js';
// shared key means the last window closed overwrites everyone else.
const isMainWindow = appWindow.label === 'main';

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);
}
}

// Persisted through Rust, not localStorage: setItem is an async message
// to the WebKit storage process that dies in transit when the last
// window's close ends the process (reproduced in QA as "close secondary
Expand Down Expand Up @@ -451,16 +463,8 @@ import { t } from './utils/i18n.js';
});
if (response !== 'discard') return;
}
await discardPersistedWindowState();
isForceExiting = true;
if (isMainWindow) {
try {
await invoke('clear_window_state');
} catch (e) {
console.error('Failed to clear window state:', e);
}
localStorage.removeItem(WINDOW_STATE_KEY);
localStorage.removeItem(LEGACY_STATE_KEY);
}
}
appWindow.close();
}
Expand Down Expand Up @@ -2353,17 +2357,33 @@ import { t } from './utils/i18n.js';
}
}

let debounceTimer: number;
let previewRenderRevision = 0;

$effect(() => {
const tab = tabManager.activeTab;
const renderRevision = ++previewRenderRevision;
if (tab && (tab.isSplit || (isEditing && settings.showToc)) && tab.rawContent !== undefined) {
if ((tab as any)._lastRenderedRawContent === tab.rawContent) return;

clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
renderTabPreviewFromRaw(tab).catch(console.error);
const tabId = tab.id;
const rawContent = tab.rawContent;
if ((tab as any)._lastRenderedRawContent === rawContent) return;

const timer = setTimeout(() => {
renderMarkdownPreview(rawContent, tab.path)
.then((processed) => {
const currentTab = tabManager.activeTab;
if (
previewRenderRevision !== renderRevision ||
tabManager.activeTabId !== tabId ||
currentTab?.rawContent !== rawContent
) return;
tabManager.updateTabContent(tabId, processed);
(currentTab as any)._lastRenderedRawContent = rawContent;
tick().then(renderRichContent);
})
.catch(console.error);
}, 16);

return () => clearTimeout(timer);
}
});

Expand Down Expand Up @@ -2739,9 +2759,11 @@ import { t } from './utils/i18n.js';

onMount(() => {
loadRecentFiles();
let disposed = false;

// @ts-ignore
Promise.all([import('highlight.js'), import('highlightjs-svelte'), import('katex'), import('mermaid')]).then(async ([hljsModule, svelteModule, katexMainModule, mermaidModule]) => {
if (disposed) return;
hljs = hljsModule.default;
try {
svelteModule.default(hljs);
Expand All @@ -2757,6 +2779,7 @@ import { t } from './utils/i18n.js';
import('katex/dist/contrib/mhchem.js'),
import('katex/dist/contrib/copy-tex.js')
]);
if (disposed) return;

renderMathInElement = autoRenderModule.default;
mermaid = mermaidModule.default;
Expand All @@ -2769,6 +2792,7 @@ import { t } from './utils/i18n.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
Expand All @@ -2782,25 +2806,43 @@ import { t } from './utils/i18n.js';
localStorage.getItem(WINDOW_STATE_KEY) ??
localStorage.getItem(LEGACY_STATE_KEY) ??
((await invoke('load_window_state').catch(() => null)) as string | null);
if (savedData) {
tabManager.restoreState(savedData);
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]) {
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);
}
}
}
Expand Down Expand Up @@ -2854,7 +2896,9 @@ import { t } from './utils/i18n.js';
const fileParam = urlParams.get('file');
if (fileParam) {
const decodedPath = decodeURIComponent(fileParam);
if (disposed) return;
await loadMarkdown(decodedPath);
if (disposed) return;
}

unlisteners.push(
Expand Down Expand Up @@ -3134,27 +3178,33 @@ import { t } from './utils/i18n.js';
}),
);

if (disposed) {
unlisteners.forEach((unlisten) => unlisten());
return;
}

// Startup-file delivery (argv / macOS Opened-before-ready stash) is
// a boot-time channel that belongs to the FIRST window only. It is
// process-global state: letting every window consume it meant each
// detached window re-opened the file the app was launched with.
if (isMainWindow) {
try {
const args: string[] = await invoke('send_markdown_path');
if (args?.length > 0) {
if (!disposed && args?.length > 0) {
await loadMarkdown(args[0]);
}
} catch (error) {
console.error('Error receiving Markdown file path:', error);
}
}

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

init();

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