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
13 changes: 7 additions & 6 deletions scripts/largeFileLoadRevision.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>\(\);/);
assert.match(viewer, /const fullLoadRevision = \(loadRevisionByTab\.get\(activeId\) \?\? 0\) \+ 1;/);
assert.match(viewer, /loadRevisionByTab\.set\(activeId, fullLoadRevision\);/);
assert.match(session, /const loadRevisionByTab = new Map<string, number>\(\);/);
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/,
);
});
170 changes: 28 additions & 142 deletions src/lib/MarkdownViewer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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<string>).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() {
Expand Down
131 changes: 131 additions & 0 deletions src/lib/sessions/documentSession.svelte.ts
Original file line number Diff line number Diff line change
@@ -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<string>;
isLiveMode: () => boolean;
afterLoad: () => Promise<unknown>;
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<string, number>();
const loadingTabs = new Set<string>();

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<string>)
.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 };
}
Loading