diff --git a/scripts/interruptedSessionRestore.test.ts b/scripts/interruptedSessionRestore.test.ts new file mode 100644 index 0000000..ce795f2 --- /dev/null +++ b/scripts/interruptedSessionRestore.test.ts @@ -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'\);/); +}); diff --git a/scripts/issue261EditorPdf.test.ts b/scripts/issue261EditorPdf.test.ts new file mode 100644 index 0000000..97c6aea --- /dev/null +++ b/scripts/issue261EditorPdf.test.ts @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +const viewer = readFileSync('src/lib/MarkdownViewer.svelte', 'utf8'); +const styles = readFileSync('src/styles.css', 'utf8'); + +test('editor context menu is not intercepted by the document menu', () => { + const start = viewer.indexOf('function handleContextMenu(e: MouseEvent)'); + const handler = viewer.slice(start, viewer.indexOf('\n\tfunction handleMouseOver', start)); + const editorReturn = handler.indexOf('if (isInsideEditor) return;'); + const preventDefault = handler.indexOf('e.preventDefault();'); + + assert.ok(editorReturn !== -1, 'editor context menus must stay with Monaco'); + assert.ok(editorReturn < preventDefault, 'Monaco must receive the event before the document menu prevents it'); +}); + +test('print layout releases measured fold heights before text reflows', () => { + assert.match( + styles, + /@media print\s*\{[\s\S]*?\.foldable-content-wrapper\s*\{[\s\S]*?height:\s*auto\s*!important;/, + ); + assert.match( + styles, + /@media print\s*\{[\s\S]*?\.foldable-content-wrapper\.is-collapsed\s*\{[\s\S]*?height:\s*0\s*!important;/, + ); +}); diff --git a/scripts/previewRenderRevision.test.ts b/scripts/previewRenderRevision.test.ts new file mode 100644 index 0000000..64a3910 --- /dev/null +++ b/scripts/previewRenderRevision.test.ts @@ -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/); +}); diff --git a/scripts/viewerDisposal.test.ts b/scripts/viewerDisposal.test.ts new file mode 100644 index 0000000..0a65840 --- /dev/null +++ b/scripts/viewerDisposal.test.ts @@ -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); +}); diff --git a/scripts/windowStateRestore.test.ts b/scripts/windowStateRestore.test.ts index a564364..471cd9b 100644 --- a/scripts/windowStateRestore.test.ts +++ b/scripts/windowStateRestore.test.ts @@ -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', () => { diff --git a/src/lib/MarkdownViewer.svelte b/src/lib/MarkdownViewer.svelte index f2a0cd5..8bccde3 100644 --- a/src/lib/MarkdownViewer.svelte +++ b/src/lib/MarkdownViewer.svelte @@ -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 @@ -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 @@ -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(); } @@ -2171,11 +2175,12 @@ import { t } from './utils/i18n.js'; function handleContextMenu(e: MouseEvent) { if (modalState.show) return; if (mode !== 'app') return; + const isInsideEditor = (e.target as HTMLElement).closest('.editor-container'); + if (isInsideEditor) return; e.preventDefault(); const selection = window.getSelection(); const hasSelection = selection ? selection.toString().length > 0 : false; - const isInsideEditor = (e.target as HTMLElement).closest('.editor-container'); const link = (e.target as HTMLElement).closest('a') as HTMLAnchorElement | null; const linkTarget = link ? getRelativeMarkdownTarget(link.getAttribute('href') || '') : null; const linkItems: ContextMenuItem[] = @@ -2225,13 +2230,6 @@ import { t } from './utils/i18n.js'; ...linkItems, ...copyRefItem, ...mediaItems, - ...(isEditing && isInsideEditor - ? [ - { label: t('menu.undo', uiLanguage), shortcut: 'Ctrl+Z', onClick: () => editorPane?.undo() }, - { label: t('menu.redo', uiLanguage), shortcut: 'Ctrl+Y', onClick: () => editorPane?.redo() }, - { separator: true } - ] - : []), ...(hasSelection ? [{ label: t('menu.copy', uiLanguage), onClick: () => { const selection = window.getSelection()?.toString(); if (selection) invoke('clipboard_write_text', { text: selection }); @@ -2353,17 +2351,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); } }); @@ -2739,9 +2753,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); @@ -2757,6 +2773,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; @@ -2769,6 +2786,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 @@ -2782,25 +2800,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); } } } @@ -2854,7 +2890,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( @@ -3134,6 +3172,11 @@ 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 @@ -3141,7 +3184,7 @@ import { t } from './utils/i18n.js'; 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) { @@ -3149,12 +3192,13 @@ import { t } from './utils/i18n.js'; } } - mode = appMode; + if (!disposed) mode = appMode; }; init(); return () => { + disposed = true; unlisteners.forEach((u) => u()); }; }); diff --git a/src/styles.css b/src/styles.css index 454333b..ff43128 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1386,6 +1386,17 @@ body { size: auto; } + .foldable-content-wrapper { + height: auto !important; + transition: none !important; + overflow: visible !important; + } + + .foldable-content-wrapper.is-collapsed { + height: 0 !important; + overflow: hidden !important; + } + .custom-title-bar, .tab-area, .window-controls-left,