perf: faster startup and long pages

- open the window while the notes folder loads; rebuild the search index after the page list is sent
- load mermaid and katex on demand
- vault.list reads files in parallel batches
- resolveLink uses a per-page-list lookup table
- very long pages (>300 blocks) skip layout of off-screen blocks
- perf-large: add a long-page case, fix the tasks poll

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Your NameandClaude Sonnet 5.5 committed 2026-10-15 12:25:45 +02:00
1 parent fcad23afa9
commit d5d25eaf46
10 files changed
+147 -64

No files matched your search

+9 -3
View File
@@ -67,7 +67,11 @@ async function openVault(dir) {
vault = new Vault(dir, { trash: (p) => shell.trashItem(p) });
db = openDb(path.join(dir, '.notes', 'index.sqlite'));
const pages = await vault.list();
db.rebuild(pages);
// The index is only a cache of the files, so refresh it once the window has its page list.
const opened = vault;
setTimeout(async () => {
try { if (vault === opened) db.rebuild(await vault.list()); } catch (err) { console.error('Could not rebuild the search index', err); }
}, 300).unref();
const trashDays = prefs().trashDays;
if (trashDays > 0) vault.purgeOlderThan(trashDays).catch((err) => console.error('Could not clear old trash', err));
stopWatch = vault.watch(async (ids) => {
@@ -162,7 +166,9 @@ function need() {
const handle = (channel, fn) => ipcMain.handle(channel, (_event, ...args) => fn(...args));
handle('vault:get', () => snapshot());
// The window is created while the folder is still opening; the page's first question waits for it.
let opening = null;
handle('vault:get', async () => { if (opening) await opening.catch(() => {}); return snapshot(); });
handle('vault:list', () => snapshot());
handle('vault:pick', async (kind) => {
let dir;
@@ -757,7 +763,7 @@ app.whenReady().then(async () => {
const s = settings.get();
if (s.vault && fs.existsSync(s.vault)) {
try { await openVault(s.vault); } catch (err) { console.error('Could not open notes folder', err); }
opening = openVault(s.vault).catch((err) => { console.error('Could not open notes folder', err); });
}
createWindow();
applyPrefs();
+56 -45
View File
@@ -74,57 +74,68 @@ class Vault {
isRecent(id) { const t = this.recent.get(id); return t !== undefined && Date.now() - t < 1500; }
async list() {
const pages = new Map();
// Each folder yields its pages (files first, then its subfolders, in directory order). Files are read in
// parallel batches and subfolders are walked together; the order of the result stays the same.
const readPage = async (dir, parent, e) => {
const canvas = /\.canvas$/i.test(e.name);
const title = canvas ? e.name.slice(0, -7) : e.name.slice(0, -3);
const id = parent ? `${parent}/${canvas ? e.name : title}` : canvas ? e.name : title;
const full = path.join(dir, e.name);
try {
const st = await fsp.stat(full);
const cached = this.cache.get(full);
let page;
if (cached && cached.mtime === st.mtimeMs) {
page = cached.page;
// update ID and parent in case it moved/renamed but mtime stayed the same
page.id = id;
page.parent = parent || null;
page.title = title;
} else {
const raw = await fsp.readFile(full, 'utf8');
// A canvas's "body" is its text and the notes on it as [[links]], for search, backlinks and the graph.
const { meta, body } = canvas ? { meta: {}, body: canvasText(raw) } : parseFrontMatter(raw);
// Front matter beyond the page's own keys: a database record's property values (see src/lib/database.js).
const props = {};
for (const k of Object.keys(meta)) if (!PAGE_KEYS.has(k)) props[k] = meta[k];
page = {
...(canvas ? { kind: 'canvas' } : {}),
props,
...(meta['ink-database'] ? { db: true } : {}),
id, parent: parent || null, title, exists: true, mtime: st.mtimeMs,
icon: meta.icon || null, cover: meta.cover || null, tags: asArray(meta.tags),
created: meta.created || today(st.birthtime && st.birthtime.getTime() ? st.birthtime : st.mtime),
body
};
this.cache.set(full, { mtime: st.mtimeMs, page });
}
return page;
} catch { return null; /* unreadable file: skip */ }
};
const walk = async (dir, parent) => {
let entries;
try { entries = await fsp.readdir(dir, { withFileTypes: true }); } catch { return; }
for (const e of entries) {
if (e.name.startsWith('.') || !e.isFile() || !/\.(md|canvas)$/i.test(e.name)) continue;
const canvas = /\.canvas$/i.test(e.name);
const title = canvas ? e.name.slice(0, -7) : e.name.slice(0, -3);
const id = parent ? `${parent}/${canvas ? e.name : title}` : canvas ? e.name : title;
const full = path.join(dir, e.name);
try {
const st = await fsp.stat(full);
const cached = this.cache.get(full);
let page;
if (cached && cached.mtime === st.mtimeMs) {
page = cached.page;
// update ID and parent in case it moved/renamed but mtime stayed the same
page.id = id;
page.parent = parent || null;
page.title = title;
} else {
const raw = await fsp.readFile(full, 'utf8');
// A canvas's "body" is its text and the notes on it as [[links]], for search, backlinks and the graph.
const { meta, body } = canvas ? { meta: {}, body: canvasText(raw) } : parseFrontMatter(raw);
// Front matter beyond the page's own keys: a database record's property values (see src/lib/database.js).
const props = {};
for (const k of Object.keys(meta)) if (!PAGE_KEYS.has(k)) props[k] = meta[k];
page = {
...(canvas ? { kind: 'canvas' } : {}),
props,
...(meta['ink-database'] ? { db: true } : {}),
id, parent: parent || null, title, exists: true, mtime: st.mtimeMs,
icon: meta.icon || null, cover: meta.cover || null, tags: asArray(meta.tags),
created: meta.created || today(st.birthtime && st.birthtime.getTime() ? st.birthtime : st.mtime),
body
};
this.cache.set(full, { mtime: st.mtimeMs, page });
}
pages.set(id, page);
} catch { /* unreadable file: skip */ }
try { entries = await fsp.readdir(dir, { withFileTypes: true }); } catch { return []; }
const files = entries.filter((e) => !e.name.startsWith('.') && e.isFile() && /\.(md|canvas)$/i.test(e.name));
const out = [];
const taken = new Set();
for (let i = 0; i < files.length; i += 64) {
const batch = await Promise.all(files.slice(i, i + 64).map((e) => readPage(dir, parent, e)));
for (const page of batch) if (page) { out.push(page); taken.add(page.id); }
}
for (const e of entries) {
if (e.name.startsWith('.') || !e.isDirectory() || SKIP_DIRS.has(e.name)) continue;
const dirs = entries.filter((e) => !e.name.startsWith('.') && e.isDirectory() && !SKIP_DIRS.has(e.name));
const subs = await Promise.all(dirs.map((e) => walk(path.join(dir, e.name), parent ? `${parent}/${e.name}` : e.name)));
dirs.forEach((e, i) => {
const id = parent ? `${parent}/${e.name}` : e.name;
if (!pages.has(id)) {
pages.set(id, { id, parent: parent || null, title: e.name, exists: false, mtime: 0, icon: null, cover: null, tags: [], created: null, body: '' });
if (!taken.has(id)) {
out.push({ id, parent: parent || null, title: e.name, exists: false, mtime: 0, icon: null, cover: null, tags: [], created: null, body: '' });
taken.add(id);
}
await walk(path.join(dir, e.name), id);
}
for (const p of subs[i]) out.push(p);
});
return out;
};
await walk(this.root, '');
const pages = new Map();
for (const p of await walk(this.root, '')) pages.set(p.id, p);
const active = new Set([...pages.values()].filter(p => p.exists).map(p => this.abs(p.id)));
for (const k of this.cache.keys()) {
if (!active.has(k)) this.cache.delete(k);