diff --git a/scripts/largeFileLoadRevision.test.ts b/scripts/largeFileLoadRevision.test.ts index a5aeeb1..1f0ac71 100644 --- a/scripts/largeFileLoadRevision.test.ts +++ b/scripts/largeFileLoadRevision.test.ts @@ -1,15 +1,16 @@ import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import test from 'node:test'; -const viewer = readFileSync(new URL('../src/lib/MarkdownViewer.svelte', import.meta.url), 'utf8'); +const sessionPath = new URL('../src/lib/sessions/documentSession.svelte.ts', import.meta.url); +const session = existsSync(sessionPath) ? readFileSync(sessionPath, 'utf8') : ''; test('large-file completion requires the current clean load revision and unchanged view mode', () => { - assert.match(viewer, /const loadRevisionByTab = new Map\(\);/); - assert.match(viewer, /const fullLoadRevision = \(loadRevisionByTab\.get\(activeId\) \?\? 0\) \+ 1;/); - assert.match(viewer, /loadRevisionByTab\.set\(activeId, fullLoadRevision\);/); + assert.match(session, /const loadRevisionByTab = new Map\(\);/); + assert.match(session, /const fullLoadRevision = \(loadRevisionByTab\.get\(activeId\) \?\? 0\) \+ 1;/); + assert.match(session, /loadRevisionByTab\.set\(activeId, fullLoadRevision\);/); assert.match( - viewer, + session, /loadRevisionByTab\.get\(activeId\) === fullLoadRevision[\s\S]*!targetTab\.isDirty[\s\S]*targetTab\.isEditing === initialIsEditing[\s\S]*targetTab\.isSplit === initialIsSplit/, ); }); diff --git a/src/lib/MarkdownViewer.svelte b/src/lib/MarkdownViewer.svelte index 10712bd..52243a7 100644 --- a/src/lib/MarkdownViewer.svelte +++ b/src/lib/MarkdownViewer.svelte @@ -53,6 +53,7 @@ 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'; +import { createDocumentSession, type LoadMarkdownOptions } from './sessions/documentSession.svelte.js'; // syntax highlighting & latex let hljs: any = $state(null); @@ -467,6 +468,32 @@ import { createWindowSession } from './sessions/windowSession.svelte.js'; onWarning: (message, error) => console.warn(message, error), }); + const documentSession = createDocumentSession({ + setShowHome: (value) => (showHome = value), + currentFile: () => currentFile, + resetScrollHistory: () => { + scrollHistory = []; + scrollFuture = []; + }, + renderMarkdown: renderMarkdownPreview, + isLiveMode: () => liveMode, + afterLoad: tick, + saveRecentFile, + deleteRecentFile, + setLoadingTabs: (tabIds) => (loadingTabs = tabIds), + measureInitialViewport: () => { + tick().then(() => { + if (markdownBody) isAtBottom = markdownBody.scrollHeight <= markdownBody.clientHeight + 100; + }); + }, + isScrolling: () => isScrolling, + renderRichContent, + onError: (message, error) => { + console.error(message, error); + addToast(`${message}: ${String(error)}`, 'error'); + }, + }); + async function discardPersistedWindowState() { await windowSession.discardPersistedState(); } @@ -850,149 +877,8 @@ import { createWindowSession } from './sessions/windowSession.svelte.js'; } } - type LoadMarkdownOptions = { - navigate?: boolean; - skipTabManagement?: boolean; - preserveEditState?: boolean; - resetScrollHistory?: boolean; - }; - async function loadMarkdown(filePath: string, options: LoadMarkdownOptions = {}) { - showHome = false; - let existing = null; - let pendingNavigateTabId: string | null = null; - try { - if (options.resetScrollHistory || filePath !== currentFile) { - scrollHistory = []; - scrollFuture = []; - } - if (options.navigate && tabManager.activeTab) { - pendingNavigateTabId = tabManager.activeTab.id; - } else if (!options.skipTabManagement) { - existing = tabManager.tabs.find((t) => t.path === filePath); - if (existing) { - tabManager.setActive(existing.id); - } else if (tabManager.activeTab && tabManager.activeTab.path === '' && !tabManager.activeTab.isDirty && tabManager.activeTab.rawContent.trim() === '') { - tabManager.updateTabPath(tabManager.activeTab.id, filePath); - } else { - tabManager.addTab(filePath); - } - } - const activeId = tabManager.activeTabId; - if (!activeId) return; - const fullLoadRevision = (loadRevisionByTab.get(activeId) ?? 0) + 1; - loadRevisionByTab.set(activeId, fullLoadRevision); - - const isMarkdown = hasMarkdownLinkExtension(filePath); - const tab = tabManager.tabs.find((t) => t.id === activeId); - - if (isMarkdown) { - // Only set default edit mode if it's a brand new tab or we aren't preserving state - if (tab && !options.preserveEditState && !existing) { - tab.isEditing = settings.startInEditor; - } - const initialIsEditing = tab?.isEditing ?? false; - const initialIsSplit = tab?.isSplit ?? false; - const [, content, isFull] = await invoke('open_markdown_preview', { path: filePath, maxBytes: 50000 }) as [string, string, boolean]; - if (pendingNavigateTabId) { - tabManager.navigate(pendingNavigateTabId, filePath); - } - const processedInfo = await renderMarkdownPreview(content, filePath); - tabManager.updateTabContent(activeId, processedInfo); - tabManager.setTabRawContent(activeId, content); - - if (!isFull) { - const canApplyFullLoad = () => { - const targetTab = tabManager.tabs.find((t) => t.id === activeId); - return ( - targetTab?.path === filePath && - loadRevisionByTab.get(activeId) === fullLoadRevision && - !targetTab.isDirty && - targetTab.isEditing === initialIsEditing && - targetTab.isSplit === initialIsSplit - ); - }; - loadingTabs = [...loadingTabs, activeId]; - tick().then(() => { - if (markdownBody) isAtBottom = markdownBody.scrollHeight <= markdownBody.clientHeight + 100; - }); - (invoke('read_file_content', { path: filePath }) as Promise).then((fullContent) => { - const applyFull = () => { - try { - if (isScrolling) { - setTimeout(applyFull, 100); - return; - } - if (canApplyFullLoad()) { - renderMarkdownPreview(fullContent, filePath) - .then((fullProcessed) => { - if (!canApplyFullLoad()) { - loadingTabs = loadingTabs.filter((id) => id !== activeId); - return; - } - tabManager.updateTabContent(activeId, fullProcessed); - tabManager.setTabRawContent(activeId, fullContent); - loadingTabs = loadingTabs.filter((id) => id !== activeId); - if (tabManager.activeTabId === activeId) { - tick().then(() => { - setTimeout(renderRichContent, 10); - }); - } - }) - .catch((renderErr) => { - console.error("render full markdown error:", renderErr); - addToast('Error processing full markdown: ' + String(renderErr), 'error'); - loadingTabs = loadingTabs.filter((id) => id !== activeId); - }); - } else { - loadingTabs = loadingTabs.filter((id) => id !== activeId); - } - } catch (applyErr) { - console.error("applyFull error:", applyErr); - addToast('Error processing full markdown: ' + String(applyErr), 'error'); - loadingTabs = loadingTabs.filter((id) => id !== activeId); - } - }; - - if ('requestIdleCallback' in window) { - (window as any).requestIdleCallback(applyFull, { timeout: 2000 }); - } else { - setTimeout(applyFull, 100); - } - }).catch((e) => { - console.error("read full markdown error:", e); - addToast('Backend Error loading full markdown: ' + String(e), 'error'); - loadingTabs = loadingTabs.filter((id) => id !== activeId); - }); - } - } else { - const content = (await invoke('read_file_content', { path: filePath })) as string; - if (pendingNavigateTabId) { - tabManager.navigate(pendingNavigateTabId, filePath); - } - if (tab) tab.isEditing = true; - tabManager.setTabRawContent(activeId, content); - } - - if (liveMode) invoke('watch_file', { path: filePath }).catch(console.error); - - await tick(); - if (filePath) saveRecentFile(filePath); - } catch (error) { - console.error('Error loading file:', error); - const errStr = String(error); - if (errStr.includes('The system cannot find the file specified') || errStr.includes('No such file or directory')) { - deleteRecentFile(filePath); - if (tabManager.activeTab && tabManager.activeTab.path === filePath) { - tabManager.closeTab(tabManager.activeTab.id); - } - } else { - // Permission denials (macOS TCC) and other read failures used - // to die silently in the console, leaving an empty tab with no - // explanation. Surface them. - addToast('Error loading file: ' + errStr, 'error'); - } - } + return documentSession.loadMarkdown(filePath, options); } async function renderRichContent() { diff --git a/src/lib/sessions/documentSession.svelte.ts b/src/lib/sessions/documentSession.svelte.ts new file mode 100644 index 0000000..123cc7c --- /dev/null +++ b/src/lib/sessions/documentSession.svelte.ts @@ -0,0 +1,131 @@ +import { invoke } from '@tauri-apps/api/core'; +import { settings } from '../stores/settings.svelte.js'; +import { tabManager } from '../stores/tabs.svelte.js'; +import { hasMarkdownLinkExtension } from '../utils/markdownLinks.js'; + +export type LoadMarkdownOptions = { + navigate?: boolean; + skipTabManagement?: boolean; + preserveEditState?: boolean; + resetScrollHistory?: boolean; +}; + +type DocumentSessionOptions = { + setShowHome: (value: boolean) => void; + currentFile: () => string; + resetScrollHistory: () => void; + renderMarkdown: (raw: string, path: string) => Promise; + isLiveMode: () => boolean; + afterLoad: () => Promise; + saveRecentFile: (path: string) => void; + deleteRecentFile: (path: string) => void; + setLoadingTabs: (tabIds: string[]) => void; + measureInitialViewport: () => void; + isScrolling: () => boolean; + renderRichContent: () => void; + onError: (message: string, error: unknown) => void; +}; + +export function createDocumentSession(options: DocumentSessionOptions) { + const loadRevisionByTab = new Map(); + const loadingTabs = new Set(); + + function updateLoading(tabId: string, loading: boolean) { + if (loading) loadingTabs.add(tabId); + else loadingTabs.delete(tabId); + options.setLoadingTabs([...loadingTabs]); + } + + async function loadMarkdown(filePath: string, loadOptions: LoadMarkdownOptions = {}) { + options.setShowHome(false); + let existing = null; + let pendingNavigateTabId: string | null = null; + try { + if (loadOptions.resetScrollHistory || filePath !== options.currentFile()) { + options.resetScrollHistory(); + } + if (loadOptions.navigate && tabManager.activeTab) { + pendingNavigateTabId = tabManager.activeTab.id; + } else if (!loadOptions.skipTabManagement) { + existing = tabManager.tabs.find((tab) => tab.path === filePath); + if (existing) tabManager.setActive(existing.id); + else if (tabManager.activeTab && tabManager.activeTab.path === '' && !tabManager.activeTab.isDirty && tabManager.activeTab.rawContent.trim() === '') { + tabManager.updateTabPath(tabManager.activeTab.id, filePath); + } else tabManager.addTab(filePath); + } + const activeId = tabManager.activeTabId; + if (!activeId) return; + const fullLoadRevision = (loadRevisionByTab.get(activeId) ?? 0) + 1; + loadRevisionByTab.set(activeId, fullLoadRevision); + const isMarkdown = hasMarkdownLinkExtension(filePath); + const tab = tabManager.tabs.find((item) => item.id === activeId); + + if (isMarkdown) { + if (tab && !loadOptions.preserveEditState && !existing) tab.isEditing = settings.startInEditor; + const initialIsEditing = tab?.isEditing ?? false; + const initialIsSplit = tab?.isSplit ?? false; + const [, content, isFull] = (await invoke('open_markdown_preview', { path: filePath, maxBytes: 50000 })) as [string, string, boolean]; + if (pendingNavigateTabId) tabManager.navigate(pendingNavigateTabId, filePath); + const processed = await options.renderMarkdown(content, filePath); + tabManager.updateTabContent(activeId, processed); + tabManager.setTabRawContent(activeId, content); + + if (!isFull) { + const canApplyFullLoad = () => { + const targetTab = tabManager.tabs.find((item) => item.id === activeId); + return targetTab?.path === filePath && loadRevisionByTab.get(activeId) === fullLoadRevision && !targetTab.isDirty && targetTab.isEditing === initialIsEditing && targetTab.isSplit === initialIsSplit; + }; + updateLoading(activeId, true); + options.measureInitialViewport(); + (invoke('read_file_content', { path: filePath }) as Promise) + .then((fullContent) => { + const applyFull = () => { + try { + if (options.isScrolling()) return void setTimeout(applyFull, 100); + if (!canApplyFullLoad()) return updateLoading(activeId, false); + options.renderMarkdown(fullContent, filePath) + .then((fullProcessed) => { + if (!canApplyFullLoad()) return updateLoading(activeId, false); + tabManager.updateTabContent(activeId, fullProcessed); + tabManager.setTabRawContent(activeId, fullContent); + updateLoading(activeId, false); + if (tabManager.activeTabId === activeId) setTimeout(options.renderRichContent, 10); + }) + .catch((error) => { + options.onError('Error processing full markdown', error); + updateLoading(activeId, false); + }); + } catch (error) { + options.onError('Error processing full markdown', error); + updateLoading(activeId, false); + } + }; + if ('requestIdleCallback' in window) (window as any).requestIdleCallback(applyFull, { timeout: 2000 }); + else setTimeout(applyFull, 100); + }) + .catch((error) => { + options.onError('Backend Error loading full markdown', error); + updateLoading(activeId, false); + }); + } + } else { + const content = (await invoke('read_file_content', { path: filePath })) as string; + if (pendingNavigateTabId) tabManager.navigate(pendingNavigateTabId, filePath); + if (tab) tab.isEditing = true; + tabManager.setTabRawContent(activeId, content); + } + if (options.isLiveMode()) invoke('watch_file', { path: filePath }).catch(console.error); + await options.afterLoad(); + if (filePath) options.saveRecentFile(filePath); + } catch (error) { + console.error('Error loading file:', error); + const errorText = String(error); + if (errorText.includes('The system cannot find the file specified') || errorText.includes('No such file or directory')) { + options.deleteRecentFile(filePath); + if (tabManager.activeTab?.path === filePath) tabManager.closeTab(tabManager.activeTab.id); + } else options.onError('Error loading file', error); + } + } + + return { loadMarkdown }; +}