diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index 8d2e07fb..d3fbaed2 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -17,6 +17,17 @@ const MAIN_EXTERNALIZE_EXCLUSIONS = [ ] function rendererManualChunk(id: string): string | undefined { + const normalizedId = id.split('\\').join('/') + if (normalizedId.endsWith('/packages/app-core/src/lib/wikilinks.ts')) { + return 'app-wikilinks' + } + if (normalizedId.endsWith('/packages/app-core/src/lib/local-assets.ts')) { + return 'app-local-assets' + } + if (normalizedId.endsWith('/packages/app-core/src/store.ts')) { + return 'app-store' + } + if (!id.includes('node_modules')) return undefined if (id.includes('/react/') || id.includes('/react-dom/') || id.includes('/zustand/')) { @@ -70,6 +81,31 @@ function rendererManualChunk(id: string): string | undefined { return undefined } +function resolveRendererModulePreloads( + _filename: string, + deps: string[], + context: { hostType: 'html' | 'js' } +): string[] { + if (context.hostType === 'html') { + return deps.filter((dep) => dep.includes('vendor-react')) + } + return deps.filter((dep) => !isDeferredRendererPreload(dep)) +} + +function isDeferredRendererPreload(dep: string): boolean { + return ( + dep.includes('NoteHoverPreview-') || + dep.includes('Preview-') || + dep.includes('wardley-') || + dep.includes('vendor-markdown') || + dep.includes('vendor-highlight') || + dep.includes('vendor-d3') || + dep.includes('vendor-mermaid') || + dep.includes('vendor-jsxgraph') || + dep.includes('vendor-function-plot') + ) +} + export default defineConfig({ main: { plugins: [externalizeDepsPlugin({ exclude: MAIN_EXTERNALIZE_EXCLUSIONS })], @@ -116,9 +152,13 @@ export default defineConfig({ root: resolve(__dirname, 'src/renderer'), build: { outDir: 'out/renderer', + minify: 'esbuild', // This is a desktop app with multiple on-demand diagram stacks. // Some lazy chunks are intentionally larger than the web default. chunkSizeWarningLimit: 3500, + modulePreload: { + resolveDependencies: resolveRendererModulePreloads + }, rollupOptions: { input: { index: resolve(__dirname, 'src/renderer/index.html') }, output: { diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 36f079e6..1abda4f6 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -57,7 +57,6 @@ "electron-updater": "^6.8.3", "font-list": "^2.0.2", "function-plot": "^1.25.3", - "fuse.js": "^7.0.0", "gray-matter": "^4.0.3", "highlight.js": "^11.10.0", "jsxgraph": "^1.12.2", diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 90ea2d13..1cdd766a 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -20,6 +20,7 @@ import path from 'node:path' import { fileURLToPath } from 'node:url' import { IPC } from '@shared/ipc' import type { + NoteMeta, NoteCommentInput, NoteFolder, RemoteWorkspaceInfo, @@ -50,6 +51,8 @@ import { getVaultSettings, hasAssetsDir, importFiles, + invalidateNoteMetaCache, + invalidateVaultTextSearchCache, listAssets, listFolders, listNotes, @@ -161,6 +164,10 @@ const APP_DISCORD_URL = 'https://discord.gg/W4fWzapKS6' const APP_REPOSITORY_URL = 'https://github.com/ZenNotes/zennotes' const APP_RELEASES_URL = 'https://github.com/ZenNotes/zennotes/releases/latest' const APP_ISSUES_URL = 'https://github.com/ZenNotes/zennotes/issues' +const userDataPathOverride = process.env['ZENNOTES_USER_DATA_PATH']?.trim() +if (userDataPathOverride && (process.env['ZEN_PERF'] === '1' || !app.isPackaged)) { + app.setPath('userData', path.resolve(userDataPathOverride)) +} let currentZoomFactor = DEFAULT_ZOOM_FACTOR const pendingOpenNoteRequests: string[] = [] const pendingFloatingNoteRequests: string[] = [] @@ -829,6 +836,8 @@ async function setVault(root: string): Promise { remoteWorkspaceProfileId: null })) watcher.start(root, (ev: VaultChangeEvent) => { + invalidateNoteMetaCache(root, ev.scope === 'vault-settings' ? undefined : ev.path) + invalidateVaultTextSearchCache(root) broadcastVaultChange(ev) }) return currentVault @@ -1315,6 +1324,40 @@ async function listFontFamilies(): Promise { } } +interface ListNotesStreamRequest { + requestId?: unknown + chunkSize?: unknown + offset?: unknown +} + +interface ListNotesStreamState { + notes: NoteMeta[] + touchedAt: number +} + +const DEFAULT_LIST_NOTES_STREAM_CHUNK_SIZE = 500 +const MAX_LIST_NOTES_STREAM_CHUNK_SIZE = 1000 +const LIST_NOTES_STREAM_STATE_TTL_MS = 60_000 +const listNotesStreamStates = new Map() + +function listNotesStreamChunkSize(raw: unknown): number { + const parsed = Number.parseInt(String(raw ?? ''), 10) + if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_LIST_NOTES_STREAM_CHUNK_SIZE + return Math.min(MAX_LIST_NOTES_STREAM_CHUNK_SIZE, parsed) +} + +function listNotesStreamOffset(raw: unknown): number { + const parsed = Number.parseInt(String(raw ?? ''), 10) + return Number.isFinite(parsed) && parsed > 0 ? parsed : 0 +} + +function pruneListNotesStreamStates(): void { + const cutoff = Date.now() - LIST_NOTES_STREAM_STATE_TTL_MS + for (const [requestId, state] of listNotesStreamStates) { + if (state.touchedAt < cutoff) listNotesStreamStates.delete(requestId) + } +} + function registerIpc(): void { const handle = ( channel: string, @@ -1447,6 +1490,38 @@ function registerIpc(): void { return await listNotes(v.root) }) + handle(IPC.VAULT_LIST_NOTES_STREAM, async (_event, request: ListNotesStreamRequest) => { + if (typeof request?.requestId !== 'string' || request.requestId.length === 0) { + throw new Error('Missing list-notes stream request id') + } + const requestId = request.requestId + const chunkSize = listNotesStreamChunkSize(request.chunkSize) + const offset = listNotesStreamOffset(request.offset) + pruneListNotesStreamStates() + + let state = listNotesStreamStates.get(requestId) + if (!state || offset === 0) { + const notes = isRemoteWorkspaceActive() + ? await requireRemoteWorkspaceClient().listNotes() + : await listNotes(requireVault().root) + state = { notes, touchedAt: Date.now() } + listNotesStreamStates.set(requestId, state) + } else { + state.touchedAt = Date.now() + } + + const nextOffset = Math.min(state.notes.length, offset + chunkSize) + const done = nextOffset >= state.notes.length + const notes = state.notes.slice(offset, nextOffset) + if (done) listNotesStreamStates.delete(requestId) + return { + notes, + nextOffset, + done, + total: state.notes.length + } + }) + handle(IPC.VAULT_LIST_FOLDERS, async () => { if (isRemoteWorkspaceActive()) return await requireRemoteWorkspaceClient().listFolders() const v = requireVault() diff --git a/apps/desktop/src/main/vault.perf.test.ts b/apps/desktop/src/main/vault.perf.test.ts new file mode 100644 index 00000000..8bdabb27 --- /dev/null +++ b/apps/desktop/src/main/vault.perf.test.ts @@ -0,0 +1,117 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { afterAll, describe, expect, it } from 'vitest' +import { + ensureVaultLayout, + invalidateNoteMetaCache, + listNotes, + readNote, + searchVaultText, + writeNote +} from './vault' + +const NOTE_COUNT = 5_000 +const FOLDER_COUNT = 50 +const WRITE_BATCH_SIZE = 250 + +const tempDirs: string[] = [] + +async function makeTempDir(prefix: string): Promise { + const dir = await mkdtemp(path.join(os.tmpdir(), prefix)) + tempDirs.push(dir) + return dir +} + +afterAll(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) +}) + +async function measure( + label: string, + detail: Record, + fn: () => Promise +): Promise<{ value: T; durationMs: number }> { + const startedAt = performance.now() + const value = await fn() + const durationMs = Math.round((performance.now() - startedAt) * 100) / 100 + console.info(`[zen:bench] ${label} ${durationMs.toFixed(2)}ms`, detail) + return { value, durationMs } +} + +async function seedLargeVault(root: string): Promise { + await ensureVaultLayout(root) + await rm(path.join(root, 'inbox', 'Welcome.md'), { force: true }) + + const writes: Array<() => Promise> = [] + for (let index = 0; index < NOTE_COUNT; index += 1) { + const folder = `folder-${String(index % FOLDER_COUNT).padStart(2, '0')}` + const title = `Note ${String(index).padStart(5, '0')}` + const rel = path.join(root, 'inbox', folder, `${title}.md`) + const needle = `needle-${index}` + const body = [ + `# ${title}`, + '', + `This synthetic benchmark note includes ${needle}.`, + 'It has tags #perf #benchmark and enough body text to exercise metadata parsing.', + 'The quick brown fox jumps over the lazy dog while ZenNotes indexes markdown.', + 'A wikilink [[Synthetic Reference]] keeps wikilink extraction on the hot path.', + '', + '- [ ] task item for task extraction adjacent workloads', + '- [x] completed task item', + '', + 'Final paragraph with repeated benchmark words for text search scoring.' + ].join('\n') + writes.push(async () => { + await mkdir(path.dirname(rel), { recursive: true }) + await writeFile(rel, body, 'utf8') + }) + } + + for (let index = 0; index < writes.length; index += WRITE_BATCH_SIZE) { + await Promise.all(writes.slice(index, index + WRITE_BATCH_SIZE).map((write) => write())) + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +describe.skipIf(process.env['ZEN_PERF_BENCH'] !== '1')('large vault performance', () => { + it('measures note listing, note read, note write, and built-in text search', async () => { + const root = await makeTempDir('zennotes-large-vault-') + await measure('seedLargeVault', { notes: NOTE_COUNT }, () => seedLargeVault(root)) + + const coldList = await measure('listNotes.cold', { notes: NOTE_COUNT }, () => listNotes(root)) + expect(coldList.value).toHaveLength(NOTE_COUNT) + + const warmList = await measure('listNotes.warm', { notes: NOTE_COUNT }, () => listNotes(root)) + expect(warmList.value).toHaveLength(NOTE_COUNT) + + await sleep(1100) + invalidateNoteMetaCache(root) + const persistedWarmList = await measure('listNotes.persistedWarm', { notes: NOTE_COUNT }, () => + listNotes(root) + ) + expect(persistedWarmList.value).toHaveLength(NOTE_COUNT) + + const target = 'inbox/folder-49/Note 04999.md' + const read = await measure('readNote', { path: target }, () => readNote(root, target)) + expect(read.value.body).toContain('needle-4999') + + const coldSearch = await measure('searchVaultText.builtin.cold', { query: 'needle-4999' }, () => + searchVaultText(root, 'needle-4999', 'builtin', {}, 20) + ) + expect(coldSearch.value.map((match) => match.path)).toContain(target) + + const warmSearch = await measure('searchVaultText.builtin.warm', { query: 'needle-4999' }, () => + searchVaultText(root, 'needle-4999', 'builtin', {}, 20) + ) + expect(warmSearch.value.map((match) => match.path)).toContain(target) + + const write = await measure('writeNote', { path: target }, () => + writeNote(root, target, `${read.value.body}\n\nBenchmark write update.\n`) + ) + expect(write.value.path).toBe(target) + }, 120_000) +}) diff --git a/apps/desktop/src/main/vault.test.ts b/apps/desktop/src/main/vault.test.ts index ce4946aa..579a46d8 100644 --- a/apps/desktop/src/main/vault.test.ts +++ b/apps/desktop/src/main/vault.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises' +import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -7,10 +7,14 @@ import { appendToNote, ensureVaultLayout, getVaultSettings, + invalidateNoteMetaCache, + listNotes, listFolders, renameFolder, + searchVaultText, searchVaultTextCapabilities, - setVaultSettings + setVaultSettings, + writeNote } from './vault' const tempDirs: string[] = [] @@ -121,3 +125,136 @@ describe('searchVaultTextCapabilities', () => { expect(capabilities.fzf).toBe(false) }) }) + +describe('searchVaultText', () => { + it('invalidates cached candidates when a note is written', async () => { + const root = await makeTempDir('zennotes-search-cache-') + await ensureVaultLayout(root) + const rel = 'inbox/cache.md' + await writeFile(path.join(root, rel), 'alpha only\n', 'utf8') + + expect((await searchVaultText(root, 'alpha', 'builtin')).map((m) => m.path)).toContain(rel) + + await writeNote(root, rel, 'beta only\n') + + expect((await searchVaultText(root, 'alpha', 'builtin')).map((m) => m.path)).not.toContain( + rel + ) + expect((await searchVaultText(root, 'beta', 'builtin')).map((m) => m.path)).toContain(rel) + }) +}) + +describe('listNotes metadata parsing', () => { + it('detects only local asset references as attachments', async () => { + const root = await makeTempDir('zennotes-meta-assets-') + await ensureVaultLayout(root) + const plainRel = 'inbox/plain.md' + const imageRel = 'inbox/image.md' + const embedRel = 'inbox/embed.md' + await writeFile(path.join(root, plainRel), '# Plain\n\n[[Project Note]]\n', 'utf8') + await writeFile(path.join(root, imageRel), '# Image\n\n![diagram](../attachements/diagram.png)\n', 'utf8') + await writeFile(path.join(root, embedRel), '# Embed\n\n![[brief.pdf]]\n', 'utf8') + + const notes = await listNotes(root) + const byPath = new Map(notes.map((note) => [note.path, note] as const)) + + expect(byPath.get(plainRel)?.hasAttachments).toBe(false) + expect(byPath.get(plainRel)?.wikilinks).toEqual(['Project Note']) + expect(byPath.get(imageRel)?.hasAttachments).toBe(true) + expect(byPath.get(embedRel)?.hasAttachments).toBe(true) + expect(byPath.get(embedRel)?.wikilinks).toEqual([]) + }) +}) + +describe('listNotes metadata cache', () => { + it('uses matching persisted metadata without reparsing unchanged note bodies', async () => { + const root = await makeTempDir('zennotes-meta-cache-hit-') + await ensureVaultLayout(root) + const rel = 'inbox/cached.md' + const abs = path.join(root, rel) + await writeFile(abs, '# Disk Title\n\n#disk\n', 'utf8') + const info = await stat(abs) + await mkdir(path.join(root, '.zennotes'), { recursive: true }) + await writeFile( + path.join(root, '.zennotes', 'note-meta-cache-v1.json'), + `${JSON.stringify({ + version: 1, + entries: [ + { + path: rel, + mtimeMs: info.mtimeMs, + size: info.size, + meta: { + path: rel, + title: 'Cached Title', + folder: 'inbox', + siblingOrder: 0, + createdAt: info.birthtimeMs || info.ctimeMs, + updatedAt: info.mtimeMs, + size: info.size, + tags: ['cached'], + wikilinks: ['Cached Target'], + hasAttachments: false, + excerpt: 'cached excerpt' + } + } + ] + })}\n`, + 'utf8' + ) + + invalidateNoteMetaCache(root) + + const notes = await listNotes(root) + const note = notes.find((item) => item.path === rel) + + expect(note?.title).toBe('Cached Title') + expect(note?.tags).toEqual(['cached']) + expect(note?.excerpt).toBe('cached excerpt') + }) + + it('ignores stale persisted metadata when file stats no longer match', async () => { + const root = await makeTempDir('zennotes-meta-cache-stale-') + await ensureVaultLayout(root) + const rel = 'inbox/stale.md' + const abs = path.join(root, rel) + await writeFile(abs, '# Fresh Title\n\n#fresh\n', 'utf8') + await mkdir(path.join(root, '.zennotes'), { recursive: true }) + await writeFile( + path.join(root, '.zennotes', 'note-meta-cache-v1.json'), + `${JSON.stringify({ + version: 1, + entries: [ + { + path: rel, + mtimeMs: 1, + size: 1, + meta: { + path: rel, + title: 'Stale Title', + folder: 'inbox', + siblingOrder: 0, + createdAt: 1, + updatedAt: 1, + size: 1, + tags: ['stale'], + wikilinks: [], + hasAttachments: false, + excerpt: 'stale excerpt' + } + } + ] + })}\n`, + 'utf8' + ) + + invalidateNoteMetaCache(root) + + const notes = await listNotes(root) + const note = notes.find((item) => item.path === rel) + + expect(note?.title).toBe('stale') + expect(note?.tags).toEqual(['fresh']) + expect(note?.excerpt).toContain('Fresh Title') + }) +}) diff --git a/apps/desktop/src/main/vault.ts b/apps/desktop/src/main/vault.ts index 131104fc..1790c2e9 100644 --- a/apps/desktop/src/main/vault.ts +++ b/apps/desktop/src/main/vault.ts @@ -4,6 +4,7 @@ import { randomUUID } from 'node:crypto' import path from 'node:path' import { promisify } from 'node:util' import { app } from 'electron' +import { recordMainPerf } from './perf' import { DEFAULT_DAILY_NOTES_DIRECTORY, AssetMeta, @@ -37,6 +38,8 @@ const LEGACY_ATTACHMENTS_DIRS = ['_assets'] const ATTACHMENTS_DIRS = [PRIMARY_ATTACHMENTS_DIR, ...LEGACY_ATTACHMENTS_DIRS] const INTERNAL_VAULT_DIR = '.zennotes' const VAULT_SETTINGS_FILE = 'vault.json' +const NOTE_META_CACHE_FILE = 'note-meta-cache-v1.json' +const NOTE_META_CACHE_VERSION = 1 const NOTE_COMMENTS_DIR = 'comments' const NOTE_COMMENTS_SUFFIX = '.comments.json' const RESERVED_ROOT_NAMES = new Set([...FOLDERS, ...ATTACHMENTS_DIRS, INTERNAL_VAULT_DIR]) @@ -65,6 +68,9 @@ const execFileAsync = promisify(execFile) const SEARCHABLE_TEXT_FOLDERS: NoteFolder[] = ['inbox', 'quick', 'archive'] const COMMAND_CHECK_TIMEOUT_MS = 1500 const SEARCH_EXEC_MAX_BUFFER = 64 * 1024 * 1024 +const SEARCH_CANDIDATE_CACHE_TTL_MS = 30_000 +const NOTE_META_READ_CONCURRENCY = 256 +const SEARCH_CANDIDATE_READ_CONCURRENCY = 256 const SEARCH_EXECUTABLE_NAMES = { ripgrep: new Set(['rg', 'rg.exe']), fzf: new Set(['fzf', 'fzf.exe']) @@ -130,6 +136,24 @@ interface ScoredVaultTextSearchCandidate extends VaultTextSearchCandidate { let cachedVaultTextSearchCapabilities: | { at: number; key: string; value: VaultTextSearchCapabilities } | null = null +let cachedVaultTextSearchCandidates: + | { + at: number + key: string + root: string + value: VaultTextSearchCandidate[] + } + | null = null +const noteMetaCache = new Map< + string, + { + mtimeMs: number + size: number + meta: NoteMeta + } +>() +const loadedPersistedNoteMetaCacheRoots = new Set() +const noteMetaCachePersistTimers = new Map>() export interface PersistedWindowState { x: number @@ -332,6 +356,10 @@ function vaultSettingsPath(root: string): string { return path.join(root, INTERNAL_VAULT_DIR, VAULT_SETTINGS_FILE) } +function noteMetaCachePath(root: string): string { + return path.join(root, INTERNAL_VAULT_DIR, NOTE_META_CACHE_FILE) +} + function noteCommentsRoot(root: string): string { return path.join(root, INTERNAL_VAULT_DIR, NOTE_COMMENTS_DIR) } @@ -585,6 +613,7 @@ function folderOf(root: string, absPath: string): NoteFolder | null { } function stripCodeContent(body: string): string { + if (!body.includes('`')) return body return body // Only treat line-start triple backticks as actual fenced blocks. .replace(FENCED_CODE_BLOCK_RE, '$1 ') @@ -605,6 +634,7 @@ function localAssetTargetKind(target: string): ImportedAssetKind | null { /** Pull unique `#tags` out of markdown text, ignoring fenced/inline code. */ function extractTags(body: string): string[] { + if (!body.includes('#')) return [] const stripped = stripCodeContent(body) const matches = stripped.match(/(?:^|\s)#([a-zA-Z][\w\-/]*)/g) || [] const seen = new Set() @@ -619,6 +649,7 @@ function extractTags(body: string): string[] { * the sidebar "has attachments" indicator. Skips fenced / inline code. */ function bodyHasLocalAsset(body: string): boolean { + if (!body.includes('](') && !body.includes('![[')) return false const stripped = stripCodeContent(body) const linkRe = /(!?)\[[^\]]*\]\((<[^>]+>|[^)\s]+)(?:\s+"[^"]*")?\)/g const embedRe = /!\[\[([^\]|]+?)(?:\|[^\]]+)?\]\]/g @@ -640,6 +671,7 @@ function bodyHasLocalAsset(body: string): boolean { /** Pull unique `[[wikilink]]` targets out of markdown text. Supports * `[[target|label]]` by discarding the label. Ignores fenced/inline code. */ function extractWikilinks(body: string): string[] { + if (!body.includes('[[')) return [] const stripped = stripCodeContent(body) const re = /(!?)\[\[([^\]|]+?)(?:\|[^\]]+)?\]\]/g const seen = new Set() @@ -656,16 +688,22 @@ function extractWikilinks(body: string): string[] { /** Build a short plaintext preview from markdown. */ function buildExcerpt(body: string): string { - const withoutFront = body.replace(/^---\n[\s\S]*?\n---\n/, '') - const text = stripCodeContent(withoutFront) - .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ') - .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') - .replace(/!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_, a, b) => b || a) - .replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_, a, b) => b || a) - .replace(/^#{1,6}\s+/gm, '') - .replace(/[*_~>]+/g, '') - .replace(/\s+/g, ' ') - .trim() + const withoutFront = body.startsWith('---\n') ? body.replace(/^---\n[\s\S]*?\n---\n/, '') : body + let text = stripCodeContent(withoutFront) + if (text.includes('](')) { + text = text + .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ') + .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') + } + if (text.includes('![[')) { + text = text.replace(/!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_, a, b) => b || a) + } + if (text.includes('[[')) { + text = text.replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_, a, b) => b || a) + } + if (text.includes('#')) text = text.replace(/^#{1,6}\s+/gm, '') + if (/[*_~>]/.test(text)) text = text.replace(/[*_~>]+/g, '') + text = text.replace(/\s+/g, ' ').trim() return text.slice(0, 220) } @@ -740,6 +778,232 @@ function capabilityCacheKey(paths: Required): string { return JSON.stringify(paths) } +function candidateCacheKey( + root: string, + source: 'builtin' | 'ripgrep', + paths: Required +): string { + return JSON.stringify({ + root: path.resolve(root), + source, + paths + }) +} + +export function invalidateVaultTextSearchCache(root?: string): void { + if (!cachedVaultTextSearchCandidates) return + if (!root || path.resolve(cachedVaultTextSearchCandidates.root) === path.resolve(root)) { + cachedVaultTextSearchCandidates = null + } +} + +function noteMetaCacheKey(root: string, abs: string): string { + return `${path.resolve(root)}\0${path.resolve(abs)}` +} + +function sameMtimeMs(left: number, right: number): boolean { + return Math.abs(left - right) < 0.001 +} + +function normalizeCachedNoteMeta(value: unknown): NoteMeta | null { + if (!value || typeof value !== 'object') return null + const candidate = value as Partial + if ( + typeof candidate.path !== 'string' || + typeof candidate.title !== 'string' || + !SYSTEM_FOLDERS.has(candidate.folder as NoteFolder) || + typeof candidate.siblingOrder !== 'number' || + typeof candidate.createdAt !== 'number' || + typeof candidate.updatedAt !== 'number' || + typeof candidate.size !== 'number' || + !Array.isArray(candidate.tags) || + !candidate.tags.every((tag) => typeof tag === 'string') || + !Array.isArray(candidate.wikilinks) || + !candidate.wikilinks.every((wikilink) => typeof wikilink === 'string') || + typeof candidate.hasAttachments !== 'boolean' || + typeof candidate.excerpt !== 'string' + ) { + return null + } + return { + path: candidate.path, + title: candidate.title, + folder: candidate.folder as NoteFolder, + siblingOrder: candidate.siblingOrder, + createdAt: candidate.createdAt, + updatedAt: candidate.updatedAt, + size: candidate.size, + tags: candidate.tags, + wikilinks: candidate.wikilinks, + hasAttachments: candidate.hasAttachments, + excerpt: candidate.excerpt + } +} + +async function hydratePersistedNoteMetaCache(root: string): Promise { + const rootAbs = path.resolve(root) + if (loadedPersistedNoteMetaCacheRoots.has(rootAbs)) return + loadedPersistedNoteMetaCacheRoots.add(rootAbs) + + try { + const raw = await fs.readFile(noteMetaCachePath(root), 'utf8') + const parsed = JSON.parse(raw) as { + version?: unknown + entries?: unknown + } + if (parsed.version !== NOTE_META_CACHE_VERSION || !Array.isArray(parsed.entries)) return + + for (const entry of parsed.entries) { + if (!entry || typeof entry !== 'object') continue + const candidate = entry as { + path?: unknown + mtimeMs?: unknown + size?: unknown + meta?: unknown + } + if ( + typeof candidate.path !== 'string' || + typeof candidate.mtimeMs !== 'number' || + typeof candidate.size !== 'number' + ) { + continue + } + const meta = normalizeCachedNoteMeta(candidate.meta) + if (!meta || meta.path !== candidate.path) continue + try { + const abs = resolveSafe(root, candidate.path) + noteMetaCache.set(noteMetaCacheKey(root, abs), { + mtimeMs: candidate.mtimeMs, + size: candidate.size, + meta + }) + } catch { + /* ignore invalid cache paths */ + } + } + } catch { + /* missing or corrupt cache files should never block vault loading */ + } +} + +function snapshotNoteMetaCache(root: string, metas: NoteMeta[]): Array<{ + path: string + mtimeMs: number + size: number + meta: NoteMeta +}> { + const entries: Array<{ path: string; mtimeMs: number; size: number; meta: NoteMeta }> = [] + for (const meta of metas) { + try { + const abs = resolveSafe(root, meta.path) + const cached = noteMetaCache.get(noteMetaCacheKey(root, abs)) + if (!cached) continue + entries.push({ + path: meta.path, + mtimeMs: cached.mtimeMs, + size: cached.size, + meta: { ...cached.meta, siblingOrder: meta.siblingOrder } + }) + } catch { + /* ignore invalid paths */ + } + } + return entries +} + +async function persistNoteMetaCacheSnapshot( + root: string, + entries: Array<{ path: string; mtimeMs: number; size: number; meta: NoteMeta }> +): Promise { + const target = noteMetaCachePath(root) + const temp = `${target}.${process.pid}.${Date.now()}.tmp` + try { + await fs.mkdir(path.dirname(target), { recursive: true }) + await fs.writeFile( + temp, + `${JSON.stringify({ version: NOTE_META_CACHE_VERSION, entries })}\n`, + 'utf8' + ) + await fs.rename(temp, target) + } catch { + await fs.rm(temp, { force: true }).catch(() => {}) + } +} + +function schedulePersistNoteMetaCache(root: string, metas: NoteMeta[]): void { + if (process.env.ZEN_PERF_DISABLE_PERSISTED_META_CACHE === '1') return + const rootAbs = path.resolve(root) + clearScheduledPersistNoteMetaCache(rootAbs) + + const timer = setTimeout(() => { + noteMetaCachePersistTimers.delete(rootAbs) + const entries = snapshotNoteMetaCache(root, metas) + if (entries.length === 0) return + void persistNoteMetaCacheSnapshot(root, entries) + }, 1000) + if (typeof timer === 'object' && 'unref' in timer) timer.unref() + noteMetaCachePersistTimers.set(rootAbs, timer) +} + +function clearScheduledPersistNoteMetaCache(rootAbs: string): void { + const existing = noteMetaCachePersistTimers.get(rootAbs) + if (existing) clearTimeout(existing) + noteMetaCachePersistTimers.delete(rootAbs) +} + +export function invalidateNoteMetaCache(root: string, rel?: string): void { + const rootAbs = path.resolve(root) + clearScheduledPersistNoteMetaCache(rootAbs) + if (!rel) { + for (const key of noteMetaCache.keys()) { + if (key.startsWith(`${rootAbs}\0`)) noteMetaCache.delete(key) + } + loadedPersistedNoteMetaCacheRoots.delete(rootAbs) + return + } + + try { + noteMetaCache.delete(noteMetaCacheKey(root, resolveSafe(root, rel))) + } catch { + /* ignore invalid relative paths */ + } +} + +async function getCachedVaultTextSearchCandidates( + root: string, + source: 'builtin' | 'ripgrep', + paths: Required, + collect: () => Promise +): Promise { + const key = candidateCacheKey(root, source, paths) + const now = Date.now() + if ( + cachedVaultTextSearchCandidates && + cachedVaultTextSearchCandidates.key === key && + now - cachedVaultTextSearchCandidates.at < SEARCH_CANDIDATE_CACHE_TTL_MS + ) { + recordMainPerf('main.vaultTextSearch.candidates.cacheHit', 0, { + source, + candidates: cachedVaultTextSearchCandidates.value.length + }) + return cachedVaultTextSearchCandidates.value + } + + const startedAt = performance.now() + const value = await collect() + cachedVaultTextSearchCandidates = { + at: Date.now(), + key, + root: path.resolve(root), + value + } + recordMainPerf('main.vaultTextSearch.candidates.refresh', performance.now() - startedAt, { + source, + candidates: value.length + }) + return value +} + async function searchExecutable( kind: 'ripgrep' | 'fzf', paths: Required @@ -817,7 +1081,7 @@ function noteFolderFromRelPath(relPath: string): NoteFolder | null { } async function collectBuiltinSearchCandidates(root: string): Promise { - const candidates: VaultTextSearchCandidate[] = [] + const files: Array<{ full: string; folder: NoteFolder }> = [] const walkFolder = async ( folder: NoteFolder, dirAbs: string, @@ -840,14 +1104,28 @@ async function collectBuiltinSearchCandidates(root: string): Promise { let body = '' try { body = await fs.readFile(full, 'utf8') } catch { - continue + return [] } + const candidates: VaultTextSearchCandidate[] = [] const relPath = toPosix(path.relative(root, full)) const title = path.basename(full, path.extname(full)) const lines = body.split('\n') @@ -865,15 +1143,10 @@ async function collectBuiltinSearchCandidates(root: string): Promise { const stat = await fs.stat(abs) + const relPath = toPosix(path.relative(root, abs)) + const cacheKey = noteMetaCacheKey(root, abs) + const cached = noteMetaCache.get(cacheKey) + const resolvedSiblingOrder = siblingOrder ?? (await readSiblingOrder(abs)) + if ( + cached && + sameMtimeMs(cached.mtimeMs, stat.mtimeMs) && + cached.size === stat.size && + cached.meta.path === relPath && + cached.meta.folder === folder + ) { + return { ...cached.meta, siblingOrder: resolvedSiblingOrder } + } + let body = '' try { body = await fs.readFile(abs, 'utf8') } catch { /* ignore — treat as empty */ } - return { - path: toPosix(path.relative(root, abs)), + const meta: NoteMeta = { + path: relPath, title: path.basename(abs, path.extname(abs)), folder, - siblingOrder: siblingOrder ?? (await readSiblingOrder(abs)), + siblingOrder: resolvedSiblingOrder, createdAt: stat.birthtimeMs || stat.ctimeMs, updatedAt: stat.mtimeMs, size: stat.size, @@ -1114,6 +1401,12 @@ async function readMeta( hasAttachments: bodyHasLocalAsset(body), excerpt: buildExcerpt(body) } + noteMetaCache.set(cacheKey, { + mtimeMs: stat.mtimeMs, + size: stat.size, + meta + }) + return meta } async function readSiblingOrder(abs: string): Promise { @@ -1127,6 +1420,30 @@ async function readSiblingOrder(abs: string): Promise { } } +async function mapLimit( + items: T[], + limit: number, + worker: (item: T, index: number) => Promise +): Promise { + const results = new Array(items.length) + let nextIndex = 0 + + const run = async (): Promise => { + while (nextIndex < items.length) { + const index = nextIndex + nextIndex += 1 + results[index] = await worker(items[index], index) + } + } + + const workers = Array.from( + { length: Math.min(limit, items.length) }, + () => run() + ) + await Promise.all(workers) + return results +} + /** * Walk every directory under the three top-level folders and return a * flat list of folder entries. This is the source of truth for the @@ -1160,7 +1477,9 @@ export async function listFolders(root: string): Promise { } export async function listNotes(root: string): Promise { - const metas: NoteMeta[] = [] + const startedAt = performance.now() + await hydratePersistedNoteMetaCache(root) + const noteFiles: Array<{ full: string; folder: NoteFolder; siblingOrder: number }> = [] const walkFolder = async ( folder: NoteFolder, dirAbs: string, @@ -1182,7 +1501,7 @@ export async function listNotes(root: string): Promise { continue } if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) { - metas.push(await readMeta(root, full, folder, index)) + noteFiles.push({ full, folder, siblingOrder: index }) } } } @@ -1192,6 +1511,21 @@ export async function listNotes(root: string): Promise { const isPrimaryRoot = folder === 'inbox' && path.resolve(topAbs) === path.resolve(root) await walkFolder(folder, topAbs, topAbs, isPrimaryRoot) } + + const metas = ( + await mapLimit(noteFiles, NOTE_META_READ_CONCURRENCY, async (file) => { + try { + return await readMeta(root, file.full, file.folder, file.siblingOrder) + } catch { + return null + } + }) + ).filter((meta): meta is NoteMeta => meta !== null) + + recordMainPerf('main.vault.listNotes', performance.now() - startedAt, { + notes: metas.length + }) + schedulePersistNoteMetaCache(root, metas) return metas } @@ -1209,22 +1543,32 @@ export async function searchVaultText( const backend = resolveSearchBackend(preferredBackend, capabilities) if (backend === 'builtin') { - const ranked = rankSearchCandidates(trimmed, await collectBuiltinSearchCandidates(root), limit) + const candidates = await getCachedVaultTextSearchCandidates(root, 'builtin', paths, () => + collectBuiltinSearchCandidates(root) + ) + const ranked = rankSearchCandidates(trimmed, candidates, limit) return await hydrateSearchOffsets(root, trimmed, ranked) } if (backend === 'ripgrep') { + const candidates = await getCachedVaultTextSearchCandidates(root, 'ripgrep', paths, () => + collectRipgrepSearchCandidates(root, paths) + ) const ranked = rankSearchCandidates( trimmed, - await collectRipgrepSearchCandidates(root, paths), + candidates, limit ) return await hydrateSearchOffsets(root, trimmed, ranked) } const candidates = capabilities.ripgrep - ? await collectRipgrepSearchCandidates(root, paths) - : await collectBuiltinSearchCandidates(root) + ? await getCachedVaultTextSearchCandidates(root, 'ripgrep', paths, () => + collectRipgrepSearchCandidates(root, paths) + ) + : await getCachedVaultTextSearchCandidates(root, 'builtin', paths, () => + collectBuiltinSearchCandidates(root) + ) const ranked = await runFzfSearch(trimmed, candidates, limit, paths) return await hydrateSearchOffsets(root, trimmed, ranked) } @@ -1251,6 +1595,8 @@ export async function writeNote(root: string, rel: string, body: string): Promis const abs = resolveSafe(root, rel) await fs.mkdir(path.dirname(abs), { recursive: true }) await fs.writeFile(abs, body, 'utf8') + invalidateNoteMetaCache(root, rel) + invalidateVaultTextSearchCache(root) const folder = folderOf(root, abs) if (!folder) throw new Error(`Note not in a known folder: ${rel}`) return await readMeta(root, abs, folder) @@ -1401,6 +1747,8 @@ export async function appendToNote( ? `${existing}${existing.endsWith('\n') ? '' : '\n'}\n${trimmedAddition}\n` : `${trimmedAddition}\n\n${existing}` await fs.writeFile(abs, next, 'utf8') + invalidateNoteMetaCache(root, rel) + invalidateVaultTextSearchCache(root) return await readMeta(root, abs, folder) } @@ -1470,6 +1818,8 @@ export async function createNote( const abs = path.join(dir, `${finalTitle}.md`) const body = `# ${finalTitle}\n\n` await fs.writeFile(abs, body, 'utf8') + invalidateNoteMetaCache(root, toPosix(path.relative(root, abs))) + invalidateVaultTextSearchCache(root) return await readMeta(root, abs, folder) } @@ -1506,6 +1856,9 @@ export async function renameNote( } const meta = await readMeta(root, target, folder) await moveNoteComments(root, rel, meta.path) + invalidateNoteMetaCache(root, rel) + invalidateNoteMetaCache(root, meta.path) + invalidateVaultTextSearchCache(root) return meta } @@ -1524,6 +1877,9 @@ async function moveBetweenFolders( await fs.rename(abs, destAbs) const meta = await readMeta(root, destAbs, target) await moveNoteComments(root, rel, meta.path) + invalidateNoteMetaCache(root, rel) + invalidateNoteMetaCache(root, meta.path) + invalidateVaultTextSearchCache(root) return meta } @@ -1551,6 +1907,8 @@ export async function emptyTrash(root: string): Promise { await Promise.all( entries.map((e) => fs.rm(path.join(trashDir, e), { recursive: true, force: true })) ) + invalidateNoteMetaCache(root) + invalidateVaultTextSearchCache(root) } catch { /* no trash dir yet */ } @@ -1560,6 +1918,8 @@ export async function deleteNote(root: string, rel: string): Promise { const abs = resolveSafe(root, rel) await fs.rm(abs, { force: true }) await removeNoteComments(root, rel) + invalidateNoteMetaCache(root, rel) + invalidateVaultTextSearchCache(root) } /* ---------- Folder operations ---------------------------------------- */ @@ -1642,6 +2002,8 @@ export async function renameFolder( ) } await setVaultSettings(root, nextSettings) + invalidateNoteMetaCache(root) + invalidateVaultTextSearchCache(root) return newClean } @@ -1664,6 +2026,8 @@ export async function deleteFolder( folderIcons: removeFolderIcons(settings.folderIcons, topFolder, clean) } await setVaultSettings(root, nextSettings) + invalidateNoteMetaCache(root) + invalidateVaultTextSearchCache(root) } /** @@ -1702,6 +2066,8 @@ export async function duplicateFolder( folderIcons: duplicateFolderIcons(settings.folderIcons, topFolder, clean, newSubpath) } await setVaultSettings(root, nextSettings) + invalidateNoteMetaCache(root) + invalidateVaultTextSearchCache(root) return newSubpath } @@ -1754,6 +2120,8 @@ export async function generateDemoTour(root: string): Promise note.path), assetPaths: DEMO_TOUR_ASSETS.map((asset) => asset.path) @@ -1771,6 +2139,8 @@ export async function removeDemoTour(root: string): Promise await removeDirIfEmpty(resolveSafe(root, DEMO_TOUR_DIR)) + invalidateNoteMetaCache(root) + invalidateVaultTextSearchCache(root) return { notePaths: DEMO_TOUR_NOTES.map((note) => note.path), assetPaths: DEMO_TOUR_ASSETS.map((asset) => asset.path) @@ -1778,8 +2148,6 @@ export async function removeDemoTour(root: string): Promise } export async function hasAssetsDir(root: string): Promise { - const assets = await listAssets(root) - if (assets.length > 0) return true for (const dirName of ATTACHMENTS_DIRS) { try { const stat = await fs.stat(path.join(root, dirName)) @@ -1867,6 +2235,9 @@ export async function moveNote( await fs.rename(oldAbs, destAbs) const meta = await readMeta(root, destAbs, targetFolder) await moveNoteComments(root, oldRel, meta.path) + invalidateNoteMetaCache(root, oldRel) + invalidateNoteMetaCache(root, meta.path) + invalidateVaultTextSearchCache(root) return meta } @@ -1883,6 +2254,8 @@ export async function duplicateNote(root: string, rel: string): Promise { try { @@ -76,6 +79,30 @@ async function refreshRemoteWorkspaceInfo(): Promise void refreshRemoteWorkspaceInfo() +function yieldRendererTask(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)) +} + +async function listNotesStreamed(): Promise { + const requestId = `${Date.now()}-${Math.random().toString(16).slice(2)}` + const notes: NoteMeta[] = [] + let offset = 0 + + for (;;) { + const page = (await ipcRenderer.invoke(IPC.VAULT_LIST_NOTES_STREAM, { + requestId, + offset, + chunkSize: LIST_NOTES_STREAM_CHUNK_SIZE + })) as ListNotesPageResponse + if (Array.isArray(page.notes) && page.notes.length > 0) { + notes.push(...page.notes) + } + if (page.done) return notes + offset = page.nextOffset + await yieldRendererTask() + } +} + function stripQueryAndHash(value: string): string { const hashIdx = value.indexOf('#') const queryIdx = value.indexOf('?') @@ -212,7 +239,9 @@ const api: ZenBridge = { setVaultSettings: (next: VaultSettings): Promise => ipcRenderer.invoke(IPC.VAULT_SET_SETTINGS, next), - listNotes: (): Promise => ipcRenderer.invoke(IPC.VAULT_LIST_NOTES), + listNotes: (): Promise => listNotesStreamed(), + listNotesPage: (request: ListNotesPageRequest): Promise => + ipcRenderer.invoke(IPC.VAULT_LIST_NOTES_STREAM, request), listFolders: (): Promise => ipcRenderer.invoke(IPC.VAULT_LIST_FOLDERS), listAssets: (): Promise => ipcRenderer.invoke(IPC.VAULT_LIST_ASSETS), hasAssetsDir: (): Promise => ipcRenderer.invoke(IPC.VAULT_HAS_ASSETS_DIR), diff --git a/apps/desktop/src/renderer/export-window.tsx b/apps/desktop/src/renderer/export-window.tsx index 8532652c..834aa821 100644 --- a/apps/desktop/src/renderer/export-window.tsx +++ b/apps/desktop/src/renderer/export-window.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState } from 'react' import ReactDOM from 'react-dom/client' import type { AssetMeta, NoteContent, NoteMeta, VaultInfo } from '@shared/ipc' -import { Preview } from '@renderer/components/Preview' +import { LazyPreview as Preview } from '@renderer/components/LazyPreview' import { useStore } from '@renderer/store' import '@renderer/styles/index.css' diff --git a/apps/server/internal/vault/parse.go b/apps/server/internal/vault/parse.go index c79eda3f..9310b8b9 100644 --- a/apps/server/internal/vault/parse.go +++ b/apps/server/internal/vault/parse.go @@ -34,6 +34,9 @@ var attachmentExts = map[string]bool{ } func stripCodeContent(body string) string { + if !strings.Contains(body, "`") { + return body + } out := fencedBlockRe.ReplaceAllString(body, "$1 ") out = inlineCodeRe.ReplaceAllString(out, " ") return out @@ -41,6 +44,9 @@ func stripCodeContent(body string) string { // ExtractTags returns unique #tags from a markdown body, ignoring code. func ExtractTags(body string) []string { + if !strings.Contains(body, "#") { + return []string{} + } stripped := stripCodeContent(body) seen := map[string]bool{} out := []string{} @@ -58,6 +64,9 @@ func ExtractTags(body string) []string { // ExtractWikilinks returns unique [[wikilink]] targets, ignoring code. func ExtractWikilinks(body string) []string { + if !strings.Contains(body, "[[") { + return []string{} + } stripped := stripCodeContent(body) seen := map[string]bool{} out := []string{} @@ -82,6 +91,9 @@ func ExtractWikilinks(body string) []string { // BodyHasLocalAsset is the same cheap heuristic as the TS version. func BodyHasLocalAsset(body string) bool { + if !strings.Contains(body, "](") && !strings.Contains(body, "![[") { + return false + } stripped := stripCodeContent(body) for _, m := range linkRe.FindAllStringSubmatch(stripped, -1) { if len(m) < 3 { @@ -117,32 +129,45 @@ func regexpMatchScheme(href string) (bool, error) { // BuildExcerpt makes a short plaintext preview from markdown. func BuildExcerpt(body string) string { - withoutFront := frontmatterRe.ReplaceAllString(body, "") + withoutFront := body + if strings.HasPrefix(body, "---\n") { + withoutFront = frontmatterRe.ReplaceAllString(body, "") + } text := stripCodeContent(withoutFront) - text = imageMdRe.ReplaceAllString(text, " ") - text = mdLinkRe.ReplaceAllString(text, "$1") - text = mdEmbedAltRe.ReplaceAllStringFunc(text, func(s string) string { - m := mdEmbedAltRe.FindStringSubmatch(s) - if len(m) >= 3 && m[2] != "" { - return m[2] - } - if len(m) >= 2 { - return m[1] - } - return "" - }) - text = mdWikiAltRe.ReplaceAllStringFunc(text, func(s string) string { - m := mdWikiAltRe.FindStringSubmatch(s) - if len(m) >= 3 && m[2] != "" { - return m[2] - } - if len(m) >= 2 { - return m[1] - } - return "" - }) - text = headingRe.ReplaceAllString(text, "") - text = markupTrimRe.ReplaceAllString(text, "") + if strings.Contains(text, "](") { + text = imageMdRe.ReplaceAllString(text, " ") + text = mdLinkRe.ReplaceAllString(text, "$1") + } + if strings.Contains(text, "![[") { + text = mdEmbedAltRe.ReplaceAllStringFunc(text, func(s string) string { + m := mdEmbedAltRe.FindStringSubmatch(s) + if len(m) >= 3 && m[2] != "" { + return m[2] + } + if len(m) >= 2 { + return m[1] + } + return "" + }) + } + if strings.Contains(text, "[[") { + text = mdWikiAltRe.ReplaceAllStringFunc(text, func(s string) string { + m := mdWikiAltRe.FindStringSubmatch(s) + if len(m) >= 3 && m[2] != "" { + return m[2] + } + if len(m) >= 2 { + return m[1] + } + return "" + }) + } + if strings.Contains(text, "#") { + text = headingRe.ReplaceAllString(text, "") + } + if strings.ContainsAny(text, "*_~>") { + text = markupTrimRe.ReplaceAllString(text, "") + } text = wsCollapseRe.ReplaceAllString(text, " ") text = strings.TrimSpace(text) if len(text) > 220 { diff --git a/apps/server/internal/vault/parse_test.go b/apps/server/internal/vault/parse_test.go new file mode 100644 index 00000000..e6ae0887 --- /dev/null +++ b/apps/server/internal/vault/parse_test.go @@ -0,0 +1,59 @@ +package vault + +import "testing" + +func TestBodyHasLocalAssetDetectsOnlyLocalAssets(t *testing.T) { + cases := []struct { + name string + body string + want bool + }{ + { + name: "plain wikilink", + body: "# Plain\n\n[[Project Note]]\n", + want: false, + }, + { + name: "relative image", + body: "# Image\n\n![diagram](../attachements/diagram.png)\n", + want: true, + }, + { + name: "embedded pdf", + body: "# Embed\n\n![[brief.pdf]]\n", + want: true, + }, + { + name: "remote image", + body: "# Remote\n\n![diagram](https://example.com/diagram.png)\n", + want: false, + }, + { + name: "code fenced local asset", + body: "# Code\n\n```md\n![diagram](local.png)\n```\n", + want: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := BodyHasLocalAsset(tc.body); got != tc.want { + t.Fatalf("BodyHasLocalAsset() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestExtractorsStillIgnoreCodeAfterFastPathGuards(t *testing.T) { + body := "# Real #tag\n\n```md\n#ignored [[Ignored]] ![[ignored.pdf]]\n```\n\n[[Target|Label]]" + + tags := ExtractTags(body) + if len(tags) != 1 || tags[0] != "tag" { + t.Fatalf("ExtractTags() = %#v, want [tag]", tags) + } + + wikilinks := ExtractWikilinks(body) + if len(wikilinks) != 1 || wikilinks[0] != "Target" { + t.Fatalf("ExtractWikilinks() = %#v, want [Target]", wikilinks) + } +} diff --git a/apps/server/internal/vault/vault.go b/apps/server/internal/vault/vault.go index 00eef1b6..9a9a3566 100644 --- a/apps/server/internal/vault/vault.go +++ b/apps/server/internal/vault/vault.go @@ -6,8 +6,10 @@ import ( "encoding/json" "errors" "fmt" + "hash/fnv" "io" "io/fs" + "math" "os" "path/filepath" "sort" @@ -20,8 +22,11 @@ const ( PrimaryAttachmentsDir = "attachements" internalVaultDir = ".zennotes" vaultSettingsFile = "vault.json" + noteMetaCacheFile = "note-meta-cache-v1.json" + noteMetaCacheVersion = 1 noteCommentsDir = "comments" noteCommentsSuffix = ".comments.json" + noteMetaReadLimit = 64 ) // ErrAssetTooLarge is returned when an asset upload exceeds the @@ -98,6 +103,12 @@ type Vault struct { dirMode fs.FileMode maxAssetBytes int64 mu sync.RWMutex + searchCacheMu sync.Mutex + searchCache *textSearchCache + metaCacheMu sync.Mutex + metaCache map[string]noteMetaCacheEntry + metaCacheLoad bool + metaCacheGen uint64 } // Options tunes vault filesystem permissions and limits. Zero values @@ -108,6 +119,68 @@ type Options struct { MaxAssetBytes int64 } +type textSearchFile struct { + abs string + relPosix string + title string + folder NoteFolder +} + +type textSearchCandidate struct { + match TextSearchMatch + lineLower string +} + +type textSearchCache struct { + signature uint64 + candidates []textSearchCandidate +} + +type noteMetaCacheEntry struct { + mtimeMs float64 + size int64 + meta NoteMeta +} + +type persistedNoteMetaCache struct { + Version int `json:"version"` + Entries []persistedNoteMetaEntry `json:"entries"` +} + +type persistedNoteMetaEntry struct { + Path string `json:"path"` + MtimeMs float64 `json:"mtimeMs"` + Size int64 `json:"size"` + Meta NoteMeta `json:"meta"` +} + +func mtimeMs(info fs.FileInfo) float64 { + return float64(info.ModTime().UnixNano()) / 1_000_000 +} + +func sameMtimeMs(a, b float64) bool { + return math.Abs(a-b) < 0.001 +} + +func (v *Vault) noteMetaCachePath() string { + return filepath.Join(v.root, internalVaultDir, noteMetaCacheFile) +} + +func (v *Vault) invalidateNoteMetaCache() { + v.metaCacheMu.Lock() + v.metaCache = map[string]noteMetaCacheEntry{} + v.metaCacheLoad = false + v.metaCacheGen++ + v.metaCacheMu.Unlock() +} + +func (v *Vault) invalidateTextSearchCache() { + v.searchCacheMu.Lock() + v.searchCache = nil + v.searchCacheMu.Unlock() + v.invalidateNoteMetaCache() +} + func New(root string, opts Options) (*Vault, error) { abs, err := filepath.Abs(root) if err != nil { @@ -130,6 +203,7 @@ func New(root string, opts Options) (*Vault, error) { fileMode: opts.FileMode, dirMode: opts.DirMode, maxAssetBytes: opts.MaxAssetBytes, + metaCache: map[string]noteMetaCacheEntry{}, } if err := v.EnsureLayout(); err != nil { return nil, err @@ -348,6 +422,7 @@ func (v *Vault) SetSettings(next VaultSettings) (VaultSettings, error) { return VaultSettings{}, err } } + v.invalidateTextSearchCache() return cloneSettings(normalized), nil } @@ -403,14 +478,147 @@ func (v *Vault) EnsureLayout() error { // --- Listing --- +func validCachedNoteMeta(meta NoteMeta, path string) bool { + if meta.Path != path || meta.Title == "" || !IsValidFolder(meta.Folder) { + return false + } + if meta.Tags == nil || meta.Wikilinks == nil { + return false + } + return true +} + +func (v *Vault) hydratePersistedNoteMetaCache() { + v.metaCacheMu.Lock() + if v.metaCacheLoad { + v.metaCacheMu.Unlock() + return + } + v.metaCacheLoad = true + v.metaCacheMu.Unlock() + + raw, err := os.ReadFile(v.noteMetaCachePath()) + if err != nil { + return + } + var persisted persistedNoteMetaCache + if err := json.Unmarshal(raw, &persisted); err != nil || persisted.Version != noteMetaCacheVersion { + return + } + + entries := map[string]noteMetaCacheEntry{} + for _, entry := range persisted.Entries { + if entry.Path == "" || !validCachedNoteMeta(entry.Meta, entry.Path) { + continue + } + abs, err := SafeJoin(v.root, entry.Path) + if err != nil { + continue + } + entries[abs] = noteMetaCacheEntry{ + mtimeMs: entry.MtimeMs, + size: entry.Size, + meta: entry.Meta, + } + } + if len(entries) == 0 { + return + } + + v.metaCacheMu.Lock() + for key, entry := range entries { + v.metaCache[key] = entry + } + v.metaCacheMu.Unlock() +} + +func (v *Vault) persistNoteMetaCacheSnapshot(metas []NoteMeta) { + if os.Getenv("ZEN_PERF_DISABLE_PERSISTED_META_CACHE") == "1" { + return + } + v.metaCacheMu.Lock() + generation := v.metaCacheGen + v.metaCacheMu.Unlock() + if len(metas) == 0 { + return + } + metas = append([]NoteMeta(nil), metas...) + + go func(metas []NoteMeta, generation uint64) { + time.Sleep(time.Second) + + entries := make([]persistedNoteMetaEntry, 0, len(metas)) + v.metaCacheMu.Lock() + if v.metaCacheGen != generation { + v.metaCacheMu.Unlock() + return + } + for _, meta := range metas { + abs, err := SafeJoin(v.root, meta.Path) + if err != nil { + continue + } + cached, ok := v.metaCache[abs] + if !ok { + continue + } + metaCopy := cached.meta + metaCopy.SiblingOrder = meta.SiblingOrder + entries = append(entries, persistedNoteMetaEntry{ + Path: meta.Path, + MtimeMs: cached.mtimeMs, + Size: cached.size, + Meta: metaCopy, + }) + } + v.metaCacheMu.Unlock() + if len(entries) == 0 { + return + } + + target := v.noteMetaCachePath() + temp := fmt.Sprintf("%s.%d.%d.tmp", target, os.Getpid(), time.Now().UnixNano()) + if err := os.MkdirAll(filepath.Dir(target), v.dirMode); err != nil { + return + } + data, err := json.Marshal(persistedNoteMetaCache{ + Version: noteMetaCacheVersion, + Entries: entries, + }) + if err != nil { + return + } + data = append(data, '\n') + if err := os.WriteFile(temp, data, v.fileMode); err != nil { + return + } + v.metaCacheMu.Lock() + stillCurrent := v.metaCacheGen == generation + v.metaCacheMu.Unlock() + if !stillCurrent { + _ = os.Remove(temp) + return + } + if err := os.Rename(temp, target); err != nil { + _ = os.Remove(temp) + } + }(metas, generation) +} + // ListNotes walks every top-level folder and returns metadata for each // note. Sibling order is the directory-listing order per folder, which // matches the TS version's behaviour for non-sorted filesystems. func (v *Vault) ListNotes() ([]NoteMeta, error) { v.mu.RLock() defer v.mu.RUnlock() + v.hydratePersistedNoteMetaCache() - out := []NoteMeta{} + type noteFile struct { + folder NoteFolder + path string + } + + files := []noteFile{} for _, folder := range AllFolders { folderRoot, err := v.folderRoot(folder) if err != nil { @@ -449,11 +657,7 @@ func (v *Vault) ListNotes() ([]NoteMeta, error) { if !strings.EqualFold(filepath.Ext(d.Name()), ".md") { return nil } - meta, err := v.readMeta(folder, path) - if err != nil { - return nil // skip unreadable files silently - } - out = append(out, meta) + files = append(files, noteFile{folder: folder, path: path}) return nil }) if err != nil { @@ -461,10 +665,42 @@ func (v *Vault) ListNotes() ([]NoteMeta, error) { } } + results := make([]NoteMeta, len(files)) + ok := make([]bool, len(files)) + limit := noteMetaReadLimit + if len(files) < limit { + limit = len(files) + } + sem := make(chan struct{}, limit) + var wg sync.WaitGroup + for index, file := range files { + wg.Add(1) + go func(index int, file noteFile) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + meta, err := v.readMeta(file.folder, file.path) + if err != nil { + return // skip unreadable files silently + } + results[index] = meta + ok[index] = true + }(index, file) + } + wg.Wait() + + out := make([]NoteMeta, 0, len(files)) + for index, meta := range results { + if ok[index] { + out = append(out, meta) + } + } + // sibling order per directory (by appearance in out for that dir) assignSiblingOrder(out, func(m NoteMeta) string { return filepath.Dir(m.Path) }, func(m *NoteMeta, i int) { m.SiblingOrder = i }) + v.persistNoteMetaCacheSnapshot(out) return out, nil } @@ -608,9 +844,6 @@ func (v *Vault) ListAssets() ([]AssetMeta, error) { func (v *Vault) HasAssetsDir() bool { v.mu.RLock() defer v.mu.RUnlock() - if assets, err := v.ListAssets(); err == nil && len(assets) > 0 { - return true - } for _, dir := range append([]string{PrimaryAttachmentsDir}, legacyAttachmentsDirs...) { info, err := os.Stat(filepath.Join(v.root, dir)) if err == nil && info.IsDir() { @@ -641,20 +874,34 @@ func (v *Vault) readMeta(folder NoteFolder, abs string) (NoteMeta, error) { if err != nil { return NoteMeta{}, err } - body, err := os.ReadFile(abs) + rel, err := filepath.Rel(v.root, abs) if err != nil { return NoteMeta{}, err } - bodyStr := string(body) + relPosix := filepath.ToSlash(rel) + statMtimeMs := mtimeMs(info) + v.metaCacheMu.Lock() + cached, ok := v.metaCache[abs] + if ok && + sameMtimeMs(cached.mtimeMs, statMtimeMs) && + cached.size == info.Size() && + cached.meta.Path == relPosix && + cached.meta.Folder == folder { + meta := cached.meta + v.metaCacheMu.Unlock() + return meta, nil + } + v.metaCacheMu.Unlock() - rel, err := filepath.Rel(v.root, abs) + body, err := os.ReadFile(abs) if err != nil { return NoteMeta{}, err } - relPosix := filepath.ToSlash(rel) + bodyStr := string(body) + title := strings.TrimSuffix(filepath.Base(abs), filepath.Ext(abs)) - return NoteMeta{ + meta := NoteMeta{ Path: relPosix, Title: title, Folder: folder, @@ -665,7 +912,15 @@ func (v *Vault) readMeta(folder NoteFolder, abs string) (NoteMeta, error) { Wikilinks: ExtractWikilinks(bodyStr), HasAttachments: BodyHasLocalAsset(bodyStr), Excerpt: BuildExcerpt(bodyStr), - }, nil + } + v.metaCacheMu.Lock() + v.metaCache[abs] = noteMetaCacheEntry{ + mtimeMs: statMtimeMs, + size: info.Size(), + meta: meta, + } + v.metaCacheMu.Unlock() + return meta, nil } func (v *Vault) ReadNote(rel string) (NoteContent, error) { @@ -715,6 +970,7 @@ func (v *Vault) WriteNote(rel, body string) (NoteMeta, error) { if err := os.WriteFile(abs, []byte(body), v.fileMode); err != nil { return NoteMeta{}, err } + v.invalidateTextSearchCache() folder, _ := v.folderOf(abs) return v.readMeta(folder, abs) } @@ -973,6 +1229,7 @@ func (v *Vault) CreateNote(folder NoteFolder, title, subpath string) (NoteMeta, if err := os.WriteFile(abs, []byte(""), v.fileMode); err != nil { return NoteMeta{}, err } + v.invalidateTextSearchCache() return v.readMeta(folder, abs) } @@ -992,6 +1249,7 @@ func (v *Vault) RenameNote(rel, nextTitle string) (NoteMeta, error) { if err := os.Rename(abs, newAbs); err != nil { return NoteMeta{}, err } + v.invalidateTextSearchCache() folder, _ := v.folderOf(newAbs) meta, err := v.readMeta(folder, newAbs) if err != nil { @@ -1013,6 +1271,7 @@ func (v *Vault) DeleteNote(rel string) error { if err := os.Remove(abs); err != nil { return err } + v.invalidateTextSearchCache() return v.removeNoteCommentsLocked(rel) } @@ -1050,6 +1309,7 @@ func (v *Vault) moveToTop(rel string, target NoteFolder) (NoteMeta, error) { if err := os.Rename(abs, newAbs); err != nil { return NoteMeta{}, err } + v.invalidateTextSearchCache() meta, err := v.readMeta(target, newAbs) if err != nil { return NoteMeta{}, err @@ -1072,6 +1332,7 @@ func (v *Vault) EmptyTrash() error { _ = v.removeNoteCommentsLocked(filepath.ToSlash(filepath.Join(string(FolderTrash), e.Name()))) _ = os.RemoveAll(filepath.Join(trashDir, e.Name())) } + v.invalidateTextSearchCache() return nil } @@ -1088,6 +1349,7 @@ func (v *Vault) DuplicateNote(rel string) (NoteMeta, error) { if err := copyFile(abs, newAbs, v.fileMode); err != nil { return NoteMeta{}, err } + v.invalidateTextSearchCache() meta, err := v.readMeta(folder, newAbs) if err != nil { return NoteMeta{}, err @@ -1127,6 +1389,7 @@ func (v *Vault) MoveNote(rel string, target NoteFolder, targetSubpath string) (N if err := os.Rename(abs, newAbs); err != nil { return NoteMeta{}, err } + v.invalidateTextSearchCache() meta, err := v.readMeta(target, newAbs) if err != nil { return NoteMeta{}, err @@ -1177,6 +1440,7 @@ func (v *Vault) RenameFolder(folder NoteFolder, oldSub, newSub string) (string, if err := os.Rename(oldAbs, newAbs); err != nil { return "", err } + v.invalidateTextSearchCache() settings, err := v.GetSettings() if err != nil { return "", err @@ -1210,6 +1474,7 @@ func (v *Vault) DeleteFolder(folder NoteFolder, subpath string) error { if err := os.RemoveAll(abs); err != nil { return err } + v.invalidateTextSearchCache() settings, err := v.GetSettings() if err != nil { return err @@ -1239,6 +1504,7 @@ func (v *Vault) DuplicateFolder(folder NoteFolder, subpath string) (string, erro if err := copyDir(src, dst, v.fileMode, v.dirMode); err != nil { return "", err } + v.invalidateTextSearchCache() settings, err := v.GetSettings() if err != nil { return "", err @@ -1334,21 +1600,17 @@ func (v *Vault) SearchCapabilities() TextSearchCapabilities { return TextSearchCapabilities{Ripgrep: false, Fzf: false} } -func (v *Vault) SearchText(query string) ([]TextSearchMatch, error) { - v.mu.RLock() - defer v.mu.RUnlock() - query = strings.TrimSpace(query) - if query == "" { - return []TextSearchMatch{}, nil - } - needle := strings.ToLower(query) - out := []TextSearchMatch{} +func (v *Vault) textSearchFilesLocked() (uint64, []textSearchFile, error) { + h := fnv.New64a() + files := []textSearchFile{} for _, folder := range []NoteFolder{FolderInbox, FolderQuick, FolderArchive} { folderRoot, err := v.folderRoot(folder) if err != nil { - return nil, err + return 0, nil, err } - isPrimaryRoot := folder == FolderInbox && filepath.Clean(folderRoot) == filepath.Clean(v.root) + cleanFolderRoot := filepath.Clean(folderRoot) + isPrimaryRoot := folder == FolderInbox && cleanFolderRoot == filepath.Clean(v.root) + fmt.Fprintf(h, "folder\x00%s\x00%s\x00%t\x00", folder, filepath.ToSlash(cleanFolderRoot), isPrimaryRoot) _ = filepath.WalkDir(folderRoot, func(path string, d os.DirEntry, err error) error { if err != nil { return nil @@ -1359,7 +1621,7 @@ func (v *Vault) SearchText(query string) ([]TextSearchMatch, error) { } if isPrimaryRoot && path != folderRoot { parent := filepath.Dir(path) - if filepath.Clean(parent) == filepath.Clean(folderRoot) { + if filepath.Clean(parent) == cleanFolderRoot { if shouldHidePrimaryRootName(d.Name()) { return filepath.SkipDir } @@ -1369,7 +1631,7 @@ func (v *Vault) SearchText(query string) ([]TextSearchMatch, error) { } if isPrimaryRoot { parent := filepath.Dir(path) - if filepath.Clean(parent) == filepath.Clean(folderRoot) { + if filepath.Clean(parent) == cleanFolderRoot { if shouldHidePrimaryRootName(d.Name()) { return nil } @@ -1378,38 +1640,126 @@ func (v *Vault) SearchText(query string) ([]TextSearchMatch, error) { if !strings.EqualFold(filepath.Ext(d.Name()), ".md") { return nil } - body, err := os.ReadFile(path) + info, err := d.Info() if err != nil { return nil } rel, _ := filepath.Rel(v.root, path) relPosix := filepath.ToSlash(rel) title := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + modNano := info.ModTime().UnixNano() + size := info.Size() + fmt.Fprintf(h, "file\x00%s\x00%s\x00%d\x00%d\x00", folder, relPosix, size, modNano) + files = append(files, textSearchFile{ + abs: path, + relPosix: relPosix, + title: title, + folder: folder, + }) + return nil + }) + } + return h.Sum64(), files, nil +} + +func (v *Vault) textSearchCandidatesLocked() ([]textSearchCandidate, error) { + signature, files, err := v.textSearchFilesLocked() + if err != nil { + return nil, err + } + + v.searchCacheMu.Lock() + if v.searchCache != nil && v.searchCache.signature == signature { + candidates := v.searchCache.candidates + v.searchCacheMu.Unlock() + return candidates, nil + } + v.searchCacheMu.Unlock() + + groups := make([][]textSearchCandidate, len(files)) + limit := noteMetaReadLimit + if len(files) < limit { + limit = len(files) + } + sem := make(chan struct{}, limit) + var wg sync.WaitGroup + for index, file := range files { + wg.Add(1) + go func(index int, file textSearchFile) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + body, err := os.ReadFile(file.abs) + if err != nil { + return + } lines := strings.Split(string(body), "\n") offset := 0 + candidates := make([]textSearchCandidate, 0, len(lines)) for i, line := range lines { - if strings.Contains(strings.ToLower(line), needle) { - collapsed := wsCollapseRe.ReplaceAllString(line, " ") - collapsed = strings.TrimSpace(collapsed) - if len(collapsed) > 220 { - collapsed = collapsed[:220] - } - out = append(out, TextSearchMatch{ - Path: relPosix, - Title: title, - Folder: folder, + collapsed := wsCollapseRe.ReplaceAllString(line, " ") + collapsed = strings.TrimSpace(collapsed) + if len(collapsed) > 220 { + collapsed = collapsed[:220] + } + candidates = append(candidates, textSearchCandidate{ + match: TextSearchMatch{ + Path: file.relPosix, + Title: file.title, + Folder: file.folder, LineNumber: i + 1, Offset: offset, LineText: collapsed, - }) - } + }, + lineLower: strings.ToLower(line), + }) offset += len(line) + 1 } - return nil - }) + groups[index] = candidates + }(index, file) + } + wg.Wait() + + candidates := []textSearchCandidate{} + for _, group := range groups { + candidates = append(candidates, group...) + } + + v.searchCacheMu.Lock() + if v.searchCache != nil && v.searchCache.signature == signature { + candidates = v.searchCache.candidates + } else { + v.searchCache = &textSearchCache{ + signature: signature, + candidates: candidates, + } } - if len(out) > 200 { - out = out[:200] + v.searchCacheMu.Unlock() + + return candidates, nil +} + +func (v *Vault) SearchText(query string) ([]TextSearchMatch, error) { + v.mu.RLock() + defer v.mu.RUnlock() + query = strings.TrimSpace(query) + if query == "" { + return []TextSearchMatch{}, nil + } + needle := strings.ToLower(query) + candidates, err := v.textSearchCandidatesLocked() + if err != nil { + return nil, err + } + out := []TextSearchMatch{} + for _, candidate := range candidates { + if !strings.Contains(candidate.lineLower, needle) { + continue + } + out = append(out, candidate.match) + if len(out) >= 200 { + break + } } return out, nil } diff --git a/apps/server/internal/vault/vault_test.go b/apps/server/internal/vault/vault_test.go index d5bd7f31..cc7e9f59 100644 --- a/apps/server/internal/vault/vault_test.go +++ b/apps/server/internal/vault/vault_test.go @@ -2,6 +2,7 @@ package vault import ( "bytes" + "encoding/json" "errors" "io" "os" @@ -9,6 +10,7 @@ import ( "runtime" "strings" "testing" + "time" ) func TestVaultDefaultModesAreTight(t *testing.T) { @@ -280,6 +282,189 @@ func TestDuplicateFolderRefusesNestedSymlink(t *testing.T) { } } +func TestSearchTextRefreshesAfterExternalChange(t *testing.T) { + root := t.TempDir() + v, err := New(root, Options{}) + if err != nil { + t.Fatal(err) + } + meta, err := v.WriteNote("inbox/Search.md", "alpha only\n") + if err != nil { + t.Fatal(err) + } + + matches, err := v.SearchText("alpha") + if err != nil { + t.Fatal(err) + } + if !textSearchMatchesPath(matches, meta.Path) { + t.Fatalf("initial search did not find %s: %#v", meta.Path, matches) + } + + abs := filepath.Join(v.Root(), filepath.FromSlash(meta.Path)) + if err := os.WriteFile(abs, []byte("beta only\n"), 0o600); err != nil { + t.Fatal(err) + } + future := time.Now().Add(2 * time.Second) + if err := os.Chtimes(abs, future, future); err != nil { + t.Fatal(err) + } + + matches, err = v.SearchText("alpha") + if err != nil { + t.Fatal(err) + } + if textSearchMatchesPath(matches, meta.Path) { + t.Fatalf("stale search result still found %s: %#v", meta.Path, matches) + } + + matches, err = v.SearchText("beta") + if err != nil { + t.Fatal(err) + } + if !textSearchMatchesPath(matches, meta.Path) { + t.Fatalf("refreshed search did not find %s: %#v", meta.Path, matches) + } +} + +func TestListNotesUsesMatchingPersistedMetadata(t *testing.T) { + root := t.TempDir() + v, err := New(root, Options{}) + if err != nil { + t.Fatal(err) + } + rel := filepath.ToSlash(filepath.Join(string(FolderInbox), "cached.md")) + abs := filepath.Join(v.Root(), filepath.FromSlash(rel)) + if err := os.WriteFile(abs, []byte("# Disk Title\n\n#disk\n"), 0o600); err != nil { + t.Fatal(err) + } + info, err := os.Stat(abs) + if err != nil { + t.Fatal(err) + } + cachePath := filepath.Join(v.Root(), internalVaultDir, noteMetaCacheFile) + if err := os.MkdirAll(filepath.Dir(cachePath), 0o700); err != nil { + t.Fatal(err) + } + cache := persistedNoteMetaCache{ + Version: noteMetaCacheVersion, + Entries: []persistedNoteMetaEntry{{ + Path: rel, + MtimeMs: mtimeMs(info), + Size: info.Size(), + Meta: NoteMeta{ + Path: rel, + Title: "Cached Title", + Folder: FolderInbox, + SiblingOrder: 0, + CreatedAt: info.ModTime().UnixMilli(), + UpdatedAt: info.ModTime().UnixMilli(), + Size: info.Size(), + Tags: []string{"cached"}, + Wikilinks: []string{"Cached Target"}, + HasAttachments: false, + Excerpt: "cached excerpt", + }, + }}, + } + raw, err := json.Marshal(cache) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(cachePath, raw, 0o600); err != nil { + t.Fatal(err) + } + v.invalidateNoteMetaCache() + + notes, err := v.ListNotes() + if err != nil { + t.Fatal(err) + } + meta, ok := findNoteMeta(notes, rel) + if !ok { + t.Fatalf("note %s not found in %#v", rel, notes) + } + if meta.Title != "Cached Title" || len(meta.Tags) != 1 || meta.Tags[0] != "cached" || meta.Excerpt != "cached excerpt" { + t.Fatalf("did not use matching persisted metadata: %#v", meta) + } +} + +func TestListNotesIgnoresStalePersistedMetadata(t *testing.T) { + root := t.TempDir() + v, err := New(root, Options{}) + if err != nil { + t.Fatal(err) + } + rel := filepath.ToSlash(filepath.Join(string(FolderInbox), "stale.md")) + abs := filepath.Join(v.Root(), filepath.FromSlash(rel)) + if err := os.WriteFile(abs, []byte("# Fresh Title\n\n#fresh\n"), 0o600); err != nil { + t.Fatal(err) + } + cachePath := filepath.Join(v.Root(), internalVaultDir, noteMetaCacheFile) + if err := os.MkdirAll(filepath.Dir(cachePath), 0o700); err != nil { + t.Fatal(err) + } + cache := persistedNoteMetaCache{ + Version: noteMetaCacheVersion, + Entries: []persistedNoteMetaEntry{{ + Path: rel, + MtimeMs: 1, + Size: 1, + Meta: NoteMeta{ + Path: rel, + Title: "Stale Title", + Folder: FolderInbox, + SiblingOrder: 0, + CreatedAt: 1, + UpdatedAt: 1, + Size: 1, + Tags: []string{"stale"}, + Wikilinks: []string{}, + HasAttachments: false, + Excerpt: "stale excerpt", + }, + }}, + } + raw, err := json.Marshal(cache) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(cachePath, raw, 0o600); err != nil { + t.Fatal(err) + } + v.invalidateNoteMetaCache() + + notes, err := v.ListNotes() + if err != nil { + t.Fatal(err) + } + meta, ok := findNoteMeta(notes, rel) + if !ok { + t.Fatalf("note %s not found in %#v", rel, notes) + } + if meta.Title != "stale" || len(meta.Tags) != 1 || meta.Tags[0] != "fresh" || !strings.Contains(meta.Excerpt, "Fresh Title") { + t.Fatalf("stale persisted metadata was not ignored: %#v", meta) + } +} + +func findNoteMeta(notes []NoteMeta, path string) (NoteMeta, bool) { + for _, note := range notes { + if note.Path == path { + return note, true + } + } + return NoteMeta{}, false +} + +func textSearchMatchesPath(matches []TextSearchMatch, path string) bool { + for _, match := range matches { + if match.Path == path { + return true + } + } + return false +} + // Compile-time assertion that ImportAsset accepts an io.Reader (silences // unused-import lints if the asset tests are stripped down later). var _ = io.Reader(bytes.NewReader(nil)) diff --git a/apps/web/package.json b/apps/web/package.json index 59096fa7..33346d22 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -29,7 +29,6 @@ "codemirror": "^6.0.1", "dompurify": "^3.3.4", "function-plot": "^1.25.3", - "fuse.js": "^7.0.0", "gray-matter": "^4.0.3", "highlight.js": "^11.10.0", "jsxgraph": "^1.12.2", diff --git a/apps/web/src/export-window.tsx b/apps/web/src/export-window.tsx index b7904319..751d082f 100644 --- a/apps/web/src/export-window.tsx +++ b/apps/web/src/export-window.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useRef, useState } from 'react' import ReactDOM from 'react-dom/client' import type { AssetMeta, NoteContent, NoteMeta, VaultInfo } from '@shared/ipc' -import { Preview } from '@renderer/components/Preview' +import { LazyPreview as Preview } from '@renderer/components/LazyPreview' import { useStore } from '@renderer/store' import '@renderer/styles/index.css' diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 5548c5c1..3445f42d 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -3,6 +3,17 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' function rendererManualChunk(id: string): string | undefined { + const normalizedId = id.split('\\').join('/') + if (normalizedId.endsWith('/packages/app-core/src/lib/wikilinks.ts')) { + return 'app-wikilinks' + } + if (normalizedId.endsWith('/packages/app-core/src/lib/local-assets.ts')) { + return 'app-local-assets' + } + if (normalizedId.endsWith('/packages/app-core/src/store.ts')) { + return 'app-store' + } + if (!id.includes('node_modules')) return undefined if (id.includes('/react/') || id.includes('/react-dom/') || id.includes('/zustand/')) { @@ -56,6 +67,31 @@ function rendererManualChunk(id: string): string | undefined { return undefined } +function resolveRendererModulePreloads( + _filename: string, + deps: string[], + context: { hostType: 'html' | 'js' } +): string[] { + if (context.hostType === 'html') { + return deps.filter((dep) => dep.includes('vendor-react')) + } + return deps.filter((dep) => !isDeferredRendererPreload(dep)) +} + +function isDeferredRendererPreload(dep: string): boolean { + return ( + dep.includes('NoteHoverPreview-') || + dep.includes('Preview-') || + dep.includes('wardley-') || + dep.includes('vendor-markdown') || + dep.includes('vendor-highlight') || + dep.includes('vendor-d3') || + dep.includes('vendor-mermaid') || + dep.includes('vendor-jsxgraph') || + dep.includes('vendor-function-plot') + ) +} + export default defineConfig({ root: __dirname, resolve: { @@ -141,6 +177,9 @@ export default defineConfig({ outDir: 'dist', emptyOutDir: true, chunkSizeWarningLimit: 3500, + modulePreload: { + resolveDependencies: resolveRendererModulePreloads + }, sourcemap: false, rollupOptions: { output: { diff --git a/package-lock.json b/package-lock.json index 35329d2a..f2a7682e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,7 +43,6 @@ "electron-updater": "^6.8.3", "font-list": "^2.0.2", "function-plot": "^1.25.3", - "fuse.js": "^7.0.0", "gray-matter": "^4.0.3", "highlight.js": "^11.10.0", "jsxgraph": "^1.12.2", @@ -114,7 +113,6 @@ "codemirror": "^6.0.1", "dompurify": "^3.3.4", "function-plot": "^1.25.3", - "fuse.js": "^7.0.0", "gray-matter": "^4.0.3", "highlight.js": "^11.10.0", "jsxgraph": "^1.12.2", @@ -8344,19 +8342,6 @@ "interval-arithmetic-eval": "^0.5.1" } }, - "node_modules/fuse.js": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.3.0.tgz", - "integrity": "sha512-plz8RVjfcDedTGfVngWH1jmJvBvAwi1v2jecfDerbEnMcmOYUEEwKFTHbNoCiYyzaK2Ws8lABkTCcRSqCY1q4w==", - "license": "Apache-2.0", - "engines": { - "node": ">=10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/krisk" - } - }, "node_modules/gauge": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", @@ -15344,7 +15329,6 @@ "@replit/codemirror-vim": "^6.3.0", "dompurify": "^3.3.4", "function-plot": "^1.25.3", - "fuse.js": "^7.0.0", "gray-matter": "^4.0.3", "hast": "^1.0.0", "highlight.js": "^11.10.0", diff --git a/package.json b/package.json index 8b0e0b6c..2e953537 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,10 @@ "test:run": "turbo run test:run", "build": "turbo run build --filter=!@zennotes/server && npm run build --workspace @zennotes/server", "build:prod": "npm run typecheck && npm run test:run && npm run build", + "perf:bench": "node tooling/scripts/perf-large-vault.mjs", + "perf:desktop-runtime": "node tooling/scripts/perf-desktop-runtime.mjs", + "perf:runtime-repeat": "node tooling/scripts/perf-runtime-repeat.mjs", + "perf:web-runtime": "node tooling/scripts/perf-web-runtime.mjs", "pack": "npm run pack --workspace @zennotes/desktop", "dist:mac": "npm run dist:mac --workspace @zennotes/desktop", "dist:win": "npm run dist:win --workspace @zennotes/desktop", diff --git a/packages/app-core/package.json b/packages/app-core/package.json index 35f36d96..2b018d94 100644 --- a/packages/app-core/package.json +++ b/packages/app-core/package.json @@ -31,7 +31,6 @@ "@lezer/highlight": "^1.2.1", "@replit/codemirror-vim": "^6.3.0", "dompurify": "^3.3.4", - "fuse.js": "^7.0.0", "function-plot": "^1.25.3", "gray-matter": "^4.0.3", "highlight.js": "^11.10.0", diff --git a/packages/app-core/src/App.tsx b/packages/app-core/src/App.tsx index 9ef5d576..db8cdc1b 100644 --- a/packages/app-core/src/App.tsx +++ b/packages/app-core/src/App.tsx @@ -1,30 +1,152 @@ -import { useEffect, useRef } from 'react' +import { lazy, Suspense, useEffect, useMemo, useRef } from 'react' import { useStore } from './store' import { resolveAuto } from './lib/themes' import { Sidebar } from './components/Sidebar' import { NoteList } from './components/NoteList' -import { Editor } from './components/Editor' import { TitleBar } from './components/TitleBar' -import { SearchPalette } from './components/SearchPalette' -import { VaultTextSearchPalette } from './components/VaultTextSearchPalette' -import { CommandPalette } from './components/CommandPalette' -import { BufferPalette } from './components/BufferPalette' -import { OutlinePalette } from './components/OutlinePalette' -import { SettingsModal } from './components/SettingsModal' -import { VimNav } from './components/VimNav' -import { EmptyVault } from './components/EmptyVault' import { PromptHost } from './components/PromptHost' import { ConfirmHost } from './components/ConfirmHost' import { ServerDirectoryPickerHost } from './components/ServerDirectoryPickerHost' -import { PinnedReferencePane } from './components/PinnedReferencePane' import { resolveQuickNoteTitle } from './lib/quick-note-title' import { matchesShortcut } from './lib/keymaps' import { requestPaneMode } from './lib/pane-mode' import { recordRendererPerf } from './lib/perf' +let editorModulePromise: Promise | null = null +const EDITOR_MODULE_WARMUP_GRACE_MS = 40 +let searchPaletteModulePromise: Promise | null = null +const SEARCH_PALETTE_MODULE_WARMUP_DELAY_MS = 40 + +function loadEditorModule(): Promise { + editorModulePromise ??= import('./components/Editor') + return editorModulePromise +} + +function loadSearchPaletteModule(): Promise { + searchPaletteModulePromise ??= import('./components/SearchPalette') + return searchPaletteModulePromise +} + +function scheduleEditorModuleWarmup(): () => void { + let cancelled = false + let delayId: number | null = null + let frameId: number | null = null + + const warmup = (): void => { + if (!cancelled) void loadEditorModule() + } + + delayId = window.setTimeout(() => { + delayId = null + frameId = window.requestAnimationFrame(() => { + frameId = null + warmup() + }) + }, EDITOR_MODULE_WARMUP_GRACE_MS) + + return () => { + cancelled = true + if (delayId !== null) window.clearTimeout(delayId) + if (frameId !== null) window.cancelAnimationFrame(frameId) + } +} + +function scheduleSearchPaletteModuleWarmup(): () => void { + let cancelled = false + let delayId: number | null = null + let frameId: number | null = null + let idleId: number | null = null + + const warmup = (): void => { + if (!cancelled) void loadSearchPaletteModule() + } + + delayId = window.setTimeout(() => { + delayId = null + if (typeof window.requestIdleCallback === 'function') { + idleId = window.requestIdleCallback( + () => { + idleId = null + warmup() + }, + { timeout: 300 } + ) + return + } + frameId = window.requestAnimationFrame(() => { + frameId = null + warmup() + }) + }, SEARCH_PALETTE_MODULE_WARMUP_DELAY_MS) + + return () => { + cancelled = true + if (delayId !== null) window.clearTimeout(delayId) + if (frameId !== null) window.cancelAnimationFrame(frameId) + if (idleId !== null && typeof window.cancelIdleCallback === 'function') { + window.cancelIdleCallback(idleId) + } + } +} + +const Editor = lazy(async () => { + const module = await loadEditorModule() + return { default: module.Editor } +}) + +const PinnedReferencePane = lazy(async () => { + const module = await import('./components/PinnedReferencePane') + return { default: module.PinnedReferencePane } +}) + +const VimNav = lazy(async () => { + const module = await import('./components/VimNav') + return { default: module.VimNav } +}) + +const SearchPalette = lazy(async () => { + const module = await loadSearchPaletteModule() + return { default: module.SearchPalette } +}) + +const VaultTextSearchPalette = lazy(async () => { + const module = await import('./components/VaultTextSearchPalette') + return { default: module.VaultTextSearchPalette } +}) + +const CommandPalette = lazy(async () => { + const module = await import('./components/CommandPalette') + return { default: module.CommandPalette } +}) + +const BufferPalette = lazy(async () => { + const module = await import('./components/BufferPalette') + return { default: module.BufferPalette } +}) + +const OutlinePalette = lazy(async () => { + const module = await import('./components/OutlinePalette') + return { default: module.OutlinePalette } +}) + +const SettingsModal = lazy(async () => { + const module = await import('./components/SettingsModal') + return { default: module.SettingsModal } +}) + +const EmptyVault = lazy(async () => { + const module = await import('./components/EmptyVault') + return { default: module.EmptyVault } +}) + +function EditorLoadingFallback(): JSX.Element { + return
+} + function App(): JSX.Element { const mountedAtRef = useRef(performance.now()) const workspaceReadyLoggedRef = useRef(false) + const searchPaletteWarmupCleanupRef = useRef<(() => void) | null>(null) const pendingOpenNoteRequestsRef = useRef([]) const vault = useStore((s) => s.vault) const init = useStore((s) => s.init) @@ -45,7 +167,11 @@ function App(): JSX.Element { const paneLayout = useStore((s) => s.paneLayout) const activePaneId = useStore((s) => s.activePaneId) const view = useStore((s) => s.view) + const selectedPath = useStore((s) => s.selectedPath) const selectedTags = useStore((s) => s.selectedTags) + const noteRefs = useStore((s) => s.noteRefs) + const pinnedRefPath = useStore((s) => s.pinnedRefPath) + const pinnedRefVisible = useStore((s) => s.pinnedRefVisible) const unifiedSidebar = useStore((s) => s.unifiedSidebar) const settingsOpen = useStore((s) => s.settingsOpen) const setSettingsOpen = useStore((s) => s.setSettingsOpen) @@ -63,11 +189,21 @@ function App(): JSX.Element { const darkSidebar = useStore((s) => s.darkSidebar) const persistWorkspace = useStore((s) => s.persistWorkspace) const flushDirtyNotes = useStore((s) => s.flushDirtyNotes) + const activePinnedRefPath = useMemo( + () => (selectedPath ? noteRefs[selectedPath]?.path ?? pinnedRefPath : pinnedRefPath), + [noteRefs, pinnedRefPath, selectedPath] + ) + const showPinnedReferencePane = !zenMode && pinnedRefVisible && !!activePinnedRefPath useEffect(() => { void init() }, [init]) + useEffect(() => { + if (!vault) return undefined + return scheduleEditorModuleWarmup() + }, [vault]) + useEffect(() => { const raf = window.requestAnimationFrame(() => { recordRendererPerf('renderer.app.mounted', performance.now() - mountedAtRef.current) @@ -108,11 +244,16 @@ function App(): JSX.Element { if (!vault || !workspaceRestored) return if (!workspaceReadyLoggedRef.current) { workspaceReadyLoggedRef.current = true - requestAnimationFrame(() => { - recordRendererPerf('renderer.workspace.ready', performance.now() - mountedAtRef.current, { - hasVault: true + void loadEditorModule() + .catch(() => null) + .then(() => { + requestAnimationFrame(() => { + recordRendererPerf('renderer.workspace.ready', performance.now() - mountedAtRef.current, { + hasVault: true + }) + searchPaletteWarmupCleanupRef.current ??= scheduleSearchPaletteModuleWarmup() + }) }) - }) } persistWorkspace() }, [ @@ -127,6 +268,13 @@ function App(): JSX.Element { workspaceRestored ]) + useEffect(() => { + return () => { + searchPaletteWarmupCleanupRef.current?.() + searchPaletteWarmupCleanupRef.current = null + } + }, []) + useEffect(() => { const flush = (): void => { void flushDirtyNotes() @@ -353,7 +501,9 @@ function App(): JSX.Element { return (
{!zenMode && } - + }> + + @@ -367,19 +517,51 @@ function App(): JSX.Element {
{!zenMode && sidebarOpen && } {!zenMode && noteListOpen && !unifiedSidebar && } - - {!zenMode && } + }> + + + {showPinnedReferencePane && ( + + + + )}
- {searchOpen && } - {vaultTextSearchOpen && } - {commandPaletteOpen && } - {bufferPaletteOpen && } - {outlinePaletteOpen && } - {settingsOpen && } + {searchOpen && ( + + + + )} + {vaultTextSearchOpen && ( + + + + )} + {commandPaletteOpen && ( + + + + )} + {bufferPaletteOpen && ( + + + + )} + {outlinePaletteOpen && ( + + + + )} + {settingsOpen && ( + + + + )} - + + +
) } diff --git a/packages/app-core/src/components/ArchiveView.tsx b/packages/app-core/src/components/ArchiveView.tsx index 9450079b..8de71709 100644 --- a/packages/app-core/src/components/ArchiveView.tsx +++ b/packages/app-core/src/components/ArchiveView.tsx @@ -7,7 +7,7 @@ import { CollectionViewHeader } from './CollectionViewHeader' import { confirmMoveToTrash } from '../lib/confirm-trash' import { ContextMenu } from './ContextMenu' import { buildMoveNotePrompt, parseMoveNoteTarget } from '../lib/move-note' -import { usePrompt } from './PromptModal' +import { promptApp } from '../lib/prompt-requests' import { advanceSequence, getKeymapBinding, matchesSequenceToken } from '../lib/keymaps' import { resolveSystemFolderLabels } from '../lib/system-folder-labels' @@ -44,7 +44,6 @@ export function ArchiveView(): JSX.Element { const systemFolderLabels = useStore((s) => s.systemFolderLabels) const workspaceMode = useStore((s) => s.workspaceMode) const amActive = useStore(isArchiveViewActive) - const { prompt, modal: promptModal } = usePrompt() const folderLabels = useMemo( () => resolveSystemFolderLabels(systemFolderLabels), [systemFolderLabels] @@ -162,7 +161,7 @@ export function ArchiveView(): JSX.Element { items.push({ label: 'Rename…', onSelect: async () => { - const next = await prompt({ + const next = await promptApp({ title: 'Rename note', initialValue: note.title, okLabel: 'Rename', @@ -178,7 +177,7 @@ export function ArchiveView(): JSX.Element { items.push({ label: 'Move…', onSelect: async () => { - const target = await prompt(buildMoveNotePrompt(note, folders)) + const target = await promptApp(buildMoveNotePrompt(note, folders)) if (!target) return const dest = parseMoveNoteTarget(target) await moveNote(note.path, dest.folder, dest.subpath) @@ -506,7 +505,6 @@ export function ArchiveView(): JSX.Element { onClose={() => setMenu(null)} /> )} - {promptModal} ) } diff --git a/packages/app-core/src/components/ConfirmHost.tsx b/packages/app-core/src/components/ConfirmHost.tsx index 34479238..740edb1e 100644 --- a/packages/app-core/src/components/ConfirmHost.tsx +++ b/packages/app-core/src/components/ConfirmHost.tsx @@ -1,54 +1,32 @@ -import { useEffect, useState } from 'react' -import { ConfirmModal, type ConfirmOptions } from './ConfirmModal' - -type ConfirmRequest = { - options: ConfirmOptions - resolve: (value: boolean) => void -} - -let currentRequest: ConfirmRequest | null = null -const listeners = new Set<(request: ConfirmRequest | null) => void>() - -function emit(): void { - for (const listener of listeners) listener(currentRequest) -} - -export function confirmApp(options: ConfirmOptions): Promise { - return new Promise((resolve) => { - currentRequest = { options, resolve } - emit() - }) -} +import { lazy, Suspense, useEffect, useState } from 'react' +import { + getConfirmRequest, + settleConfirmRequest, + subscribeConfirmRequests, + type ConfirmRequest +} from '../lib/confirm-requests' + +const ConfirmModal = lazy(async () => { + const module = await import('./ConfirmModal') + return { default: module.ConfirmModal } +}) export function ConfirmHost(): JSX.Element | null { - const [request, setRequest] = useState(currentRequest) + const [request, setRequest] = useState(getConfirmRequest) useEffect(() => { - listeners.add(setRequest) - return () => { - listeners.delete(setRequest) - } + return subscribeConfirmRequests(setRequest) }, []) if (!request) return null return ( - { - const resolve = request.resolve - currentRequest = null - setRequest(null) - queueMicrotask(() => resolve(true)) - emit() - }} - onCancel={() => { - const resolve = request.resolve - currentRequest = null - setRequest(null) - queueMicrotask(() => resolve(false)) - emit() - }} - /> + + settleConfirmRequest(request, true)} + onCancel={() => settleConfirmRequest(request, false)} + /> + ) } diff --git a/packages/app-core/src/components/ConnectionsPanel.tsx b/packages/app-core/src/components/ConnectionsPanel.tsx index a565a3d7..b13101a5 100644 --- a/packages/app-core/src/components/ConnectionsPanel.tsx +++ b/packages/app-core/src/components/ConnectionsPanel.tsx @@ -8,8 +8,8 @@ import { resolveWikilinkTarget, suggestCreateNotePath } from '../lib/wikilinks' -import { NoteHoverPreview } from './NoteHoverPreview' -import { promptApp } from './PromptHost' +import { LazyNoteHoverPreview as NoteHoverPreview } from './LazyNoteHoverPreview' +import { promptApp } from '../lib/prompt-requests' interface MentionItem { note: NoteMeta diff --git a/packages/app-core/src/components/Editor.tsx b/packages/app-core/src/components/Editor.tsx index 38c75780..b4de6414 100644 --- a/packages/app-core/src/components/Editor.tsx +++ b/packages/app-core/src/components/Editor.tsx @@ -26,7 +26,7 @@ import { parseMoveNoteTarget, validateMoveNoteTarget } from '../lib/move-note' -import { promptApp } from './PromptHost' +import { promptApp } from '../lib/prompt-requests' import { StatusBar } from './StatusBar' import { EditorPane } from './EditorPane' import { focusPaneOrEdgePanel } from '../lib/pane-nav' diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index 1e3d2c82..95ac859a 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -10,6 +10,7 @@ import { Fragment, useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState @@ -66,7 +67,7 @@ import { livePreviewPlugin } from '../lib/cm-live-preview' import { slashCommandSource, slashCommandRender } from '../lib/cm-slash-commands' import { dateShortcutSource } from '../lib/cm-date-shortcuts' import { wikilinkSource } from '../lib/cm-wikilinks' -import { Preview } from './Preview' +import { LazyPreview as Preview } from './LazyPreview' import { ConnectionsPanel } from './ConnectionsPanel' import { OutlinePanel } from './OutlinePanel' import { CommentsPanel, type CommentDraft } from './CommentsPanel' @@ -91,6 +92,7 @@ import { readImageBlockDragPayload } from '../lib/image-block-dnd' import { useSettledMarkdown } from '../lib/use-rendered-markdown' +import { recordRendererPerf } from '../lib/perf' import { parseOutline } from '../lib/outline' import { ArchiveIcon, @@ -131,6 +133,7 @@ import { } from '../lib/asset-tabs' import { classifyLocalAssetHref } from '../lib/local-assets' import { getKeymapDisplay, type KeymapId } from '../lib/keymaps' +import { isTabStripOverflowing } from '../lib/tab-strip-overflow' const MODE_OPTIONS: Array<{ mode: PaneMode @@ -148,6 +151,26 @@ const MODE_OPTIONS: Array<{ } ] +const LARGE_DOC_LIVE_PREVIEW_DEFER_CHARS = 120_000 +const LARGE_DOC_LIVE_PREVIEW_DEFER_MS = 3_000 +const LARGE_DOC_EDITOR_HYDRATE_DELAY_MS = 180 + +function markdownEditingExtensions(): Extension[] { + return [ + markdown({ base: markdownLanguage, codeLanguages: resolveCodeLanguage, addKeymap: true }), + markdownListIndentPlugin, + orderedListRenumber, + headingFolding() + ] +} + +function markdownSyntaxHighlightExtensions(): Extension[] { + return [ + syntaxHighlighting(paperHighlight), + syntaxHighlighting(defaultHighlightStyle, { fallback: true }) + ] +} + const paperHighlight = HighlightStyle.define([ // Markdown-level tokens { tag: t.heading1, class: 'tok-heading1' }, @@ -512,6 +535,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { const lineNumberMode = useStore((s) => s.lineNumberMode) const textFont = useStore((s) => s.textFont) const tabsEnabled = useStore((s) => s.tabsEnabled) + const wrapTabs = useStore((s) => s.wrapTabs) const workspaceMode = useStore((s) => s.workspaceMode) const wordWrap = useStore((s) => s.wordWrap) const systemFolderLabels = useStore((s) => s.systemFolderLabels) @@ -536,15 +560,23 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { y: number hasSelection: boolean } | null>(null) + const [editorHydration, setEditorHydration] = useState<{ + path: string + ready: boolean + } | null>(null) const [assetDropActive, setAssetDropActive] = useState(false) const [imageDropIndicatorTop, setImageDropIndicatorTop] = useState(null) + const [tabStripOverflowing, setTabStripOverflowing] = useState(false) const viewRef = useRef(null) const paneRootRef = useRef(null) + const tabStripRef = useRef(null) const paneBodyRef = useRef(null) const editorSurfaceRef = useRef(null) const previewScrollRef = useRef(null) const vimCompartmentRef = useRef(null) + const markdownCompartmentRef = useRef(null) + const markdownSyntaxCompartmentRef = useRef(null) const livePreviewCompartmentRef = useRef(null) const lineNumbersCompartmentRef = useRef(null) const wordWrapCompartmentRef = useRef(null) @@ -555,6 +587,8 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { const activeOutlineFrameRef = useRef(null) const selectionActionFrameRef = useRef(null) const taskJumpHighlightTimerRef = useRef | null>(null) + const deferredLivePreviewTimerRef = useRef | null>(null) + const richMarkdownDeferredRef = useRef(false) /** * Path currently rendered in this pane's CodeMirror view. The CM update * listener writes through to `noteContents[viewPathRef.current]`; the @@ -800,10 +834,62 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { // Outline items derived from the current note body — line numbers // here are 1-based to match `parseOutline` and the editor's doc API. const outlineItems = useMemo( - () => parseOutline(content?.body ?? ''), - [content?.body] + () => (outlineOpen ? parseOutline(content?.body ?? '') : []), + [content?.body, outlineOpen] ) + useEffect(() => { + if (!content) { + setEditorHydration(null) + return + } + + const shouldDefer = + mode !== 'preview' && + content.body.length >= LARGE_DOC_LIVE_PREVIEW_DEFER_CHARS + if (!shouldDefer) { + setEditorHydration((current) => + current?.path === content.path && current.ready + ? current + : { path: content.path, ready: true } + ) + return + } + + setEditorHydration((current) => + current?.path === content.path && current.ready + ? current + : { path: content.path, ready: false } + ) + + let cancelled = false + let idleId: number | null = null + const timeoutId = window.setTimeout(() => { + const hydrate = (): void => { + idleId = null + if (cancelled) return + setEditorHydration({ path: content.path, ready: true }) + } + + if (typeof window.requestIdleCallback === 'function') { + idleId = window.requestIdleCallback(hydrate, { timeout: 700 }) + return + } + hydrate() + }, LARGE_DOC_EDITOR_HYDRATE_DELAY_MS) + + return () => { + cancelled = true + window.clearTimeout(timeoutId) + if ( + idleId != null && + typeof window.cancelIdleCallback === 'function' + ) { + window.cancelIdleCallback(idleId) + } + } + }, [content?.body.length, content?.path, mode]) + const setActiveOutlineLineSafely = useCallback((line: number | null) => { if (activeOutlineLineRef.current === line) return activeOutlineLineRef.current = line @@ -886,6 +972,11 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { cancelAnimationFrame(selectionActionFrameRef.current) selectionActionFrameRef.current = null } + if (deferredLivePreviewTimerRef.current != null) { + clearTimeout(deferredLivePreviewTimerRef.current) + deferredLivePreviewTimerRef.current = null + } + richMarkdownDeferredRef.current = false setSelectionCommentAction(null) const existingView = viewRef.current if ( @@ -900,18 +991,27 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { } if (viewRef.current) return const vimCompartment = new Compartment() + const markdownCompartment = new Compartment() + const markdownSyntaxCompartment = new Compartment() const livePreviewCompartment = new Compartment() const lineNumbersCompartment = new Compartment() const wordWrapCompartment = new Compartment() vimCompartmentRef.current = vimCompartment + markdownCompartmentRef.current = markdownCompartment + markdownSyntaxCompartmentRef.current = markdownSyntaxCompartment livePreviewCompartmentRef.current = livePreviewCompartment lineNumbersCompartmentRef.current = lineNumbersCompartment wordWrapCompartmentRef.current = wordWrapCompartment const s0 = useStore.getState() const initialPath = findLeaf(s0.paneLayout, paneId)?.activeTab ?? null const initialContent = initialPath ? s0.noteContents[initialPath] ?? null : null + const initialBody = initialContent?.body ?? '' + const deferInitialRichMarkdown = + initialBody.length >= LARGE_DOC_LIVE_PREVIEW_DEFER_CHARS && !s0.livePreview + richMarkdownDeferredRef.current = deferInitialRichMarkdown + const stateStartedAt = performance.now() const state = EditorState.create({ - doc: initialContent?.body ?? '', + doc: initialBody, extensions: [ vimCompartment.of(s0.vimMode ? vim() : []), history(), @@ -920,13 +1020,13 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { taskJumpHighlightField, commentDecorationField, wordWrapCompartment.of(s0.wordWrap ? EditorView.lineWrapping : []), - markdown({ base: markdownLanguage, codeLanguages: resolveCodeLanguage, addKeymap: true }), - markdownListIndentPlugin, - orderedListRenumber, - headingFolding(), - syntaxHighlighting(paperHighlight), - syntaxHighlighting(defaultHighlightStyle, { fallback: true }), - livePreviewCompartment.of(s0.livePreview ? livePreviewPlugin : []), + markdownCompartment.of(deferInitialRichMarkdown ? [] : markdownEditingExtensions()), + markdownSyntaxCompartment.of( + deferInitialRichMarkdown ? [] : markdownSyntaxHighlightExtensions() + ), + livePreviewCompartment.of( + s0.livePreview && !deferInitialRichMarkdown ? livePreviewPlugin : [] + ), lineNumbersCompartment.of(lineNumberExtension(s0.lineNumberMode)), tooltips({ parent: document.body }), autocompletion({ @@ -1027,12 +1127,37 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { }) ] }) + recordRendererPerf('editor.mount.state', performance.now() - stateStartedAt, { + chars: initialBody.length, + deferred: deferInitialRichMarkdown + }) + const viewStartedAt = performance.now() const view = new EditorView({ state, parent: el }) + recordRendererPerf('editor.mount.view', performance.now() - viewStartedAt, { + chars: initialBody.length, + deferred: deferInitialRichMarkdown + }) viewRef.current = view viewPathRef.current = initialPath - if (useStore.getState().activePaneId === paneId) { + if (initialContent && useStore.getState().activePaneId === paneId) { setEditorViewRef(view) } + if (deferInitialRichMarkdown && initialPath) { + deferredLivePreviewTimerRef.current = setTimeout(() => { + deferredLivePreviewTimerRef.current = null + if (viewRef.current !== view) return + if (viewPathRef.current !== initialPath) return + richMarkdownDeferredRef.current = false + const restoreEffects = [ + markdownCompartment.reconfigure(markdownEditingExtensions()), + markdownSyntaxCompartment.reconfigure(markdownSyntaxHighlightExtensions()) + ] + if (useStore.getState().livePreview) { + restoreEffects.push(livePreviewCompartment.reconfigure(livePreviewPlugin)) + } + view.dispatch({ effects: restoreEffects }) + }, LARGE_DOC_LIVE_PREVIEW_DEFER_MS) + } }, [ openEditorContextMenu, @@ -1048,11 +1173,16 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { // Register our view as the focused editor whenever our pane is active. useEffect(() => { - if (!isActive) return const view = viewRef.current if (!view) return - setEditorViewRef(view) - }, [isActive, setEditorViewRef, activeTab]) + if (isActive && content) { + setEditorViewRef(view) + return + } + if (useStore.getState().editorViewRef === view) { + setEditorViewRef(null) + } + }, [isActive, setEditorViewRef, activeTab, content?.path]) useEffect(() => { const refresh = (): void => scheduleSelectionCommentAction() @@ -1065,26 +1195,104 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { }, [scheduleSelectionCommentAction]) // Sync CM doc to external content changes (file watcher, peer panes, tab switch). - useEffect(() => { + useLayoutEffect(() => { const view = viewRef.current if (!view) return const nextPath = content?.path ?? null const nextBody = content?.body ?? '' const pathChanged = viewPathRef.current !== nextPath - const bodyChanged = view.state.doc.toString() !== nextBody + const bodyChanged = + pathChanged || + view.state.doc.length !== nextBody.length || + view.state.doc.toString() !== nextBody if (!pathChanged && !bodyChanged) return + if (deferredLivePreviewTimerRef.current != null) { + clearTimeout(deferredLivePreviewTimerRef.current) + deferredLivePreviewTimerRef.current = null + } // Preserve selection on in-place body changes (peer pane edits, // external file watcher); jump to the start when switching tabs. const sel = view.state.selection.main const clampedAnchor = Math.min(sel.anchor, nextBody.length) const clampedHead = Math.min(sel.head, nextBody.length) + const markdownCompartment = markdownCompartmentRef.current + const markdownSyntaxCompartment = markdownSyntaxCompartmentRef.current + const livePreviewCompartment = livePreviewCompartmentRef.current + const livePreviewEnabled = useStore.getState().livePreview + const deferRichMarkdown = + pathChanged && + nextBody.length >= LARGE_DOC_LIVE_PREVIEW_DEFER_CHARS && + !livePreviewEnabled && + !!markdownCompartment && + !!markdownSyntaxCompartment + const effects: StateEffect[] = [] + if (deferRichMarkdown && markdownCompartment && markdownSyntaxCompartment) { + richMarkdownDeferredRef.current = true + effects.push( + markdownCompartment.reconfigure([]), + markdownSyntaxCompartment.reconfigure([]) + ) + if (livePreviewCompartment) effects.push(livePreviewCompartment.reconfigure([])) + } else if ( + richMarkdownDeferredRef.current && + markdownCompartment && + markdownSyntaxCompartment + ) { + richMarkdownDeferredRef.current = false + effects.push( + markdownCompartment.reconfigure(markdownEditingExtensions()), + markdownSyntaxCompartment.reconfigure(markdownSyntaxHighlightExtensions()) + ) + if (livePreviewEnabled && livePreviewCompartment) { + effects.push(livePreviewCompartment.reconfigure(livePreviewPlugin)) + } + } + const dispatchStartedAt = performance.now() view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: nextBody }, annotations: [programmatic.of(true), skipOrderedListRenumber.of(true)], + effects: effects.length > 0 ? effects : undefined, selection: pathChanged ? { anchor: 0 } : { anchor: clampedAnchor, head: clampedHead } }) + recordRendererPerf('editor.doc.sync', performance.now() - dispatchStartedAt, { + chars: nextBody.length, + deferred: deferRichMarkdown, + pathChanged + }) + requestAnimationFrame(() => { + requestAnimationFrame(() => { + recordRendererPerf('editor.doc.paint-latency', performance.now() - dispatchStartedAt, { + chars: nextBody.length, + deferred: deferRichMarkdown, + pathChanged + }) + }) + }) viewPathRef.current = nextPath - }, [content?.body, content?.path]) + if ( + deferRichMarkdown && + markdownCompartment && + markdownSyntaxCompartment && + nextPath + ) { + deferredLivePreviewTimerRef.current = setTimeout(() => { + deferredLivePreviewTimerRef.current = null + if (viewRef.current !== view) return + if (viewPathRef.current !== nextPath) return + richMarkdownDeferredRef.current = false + const restoreEffects = [ + markdownCompartment.reconfigure(markdownEditingExtensions()), + markdownSyntaxCompartment.reconfigure(markdownSyntaxHighlightExtensions()) + ] + if (useStore.getState().livePreview && livePreviewCompartment) { + restoreEffects.push(livePreviewCompartment.reconfigure(livePreviewPlugin)) + } + view.dispatch({ + effects: restoreEffects + }) + }, LARGE_DOC_LIVE_PREVIEW_DEFER_MS) + } + }, [content?.body, content?.path, livePreview]) useEffect(() => { if (!content) return @@ -1113,6 +1321,27 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { const view = viewRef.current const comp = livePreviewCompartmentRef.current if (!view || !comp) return + if (deferredLivePreviewTimerRef.current != null) { + if (livePreview) { + clearTimeout(deferredLivePreviewTimerRef.current) + deferredLivePreviewTimerRef.current = null + richMarkdownDeferredRef.current = false + const effects: StateEffect[] = [] + const markdownCompartment = markdownCompartmentRef.current + const markdownSyntaxCompartment = markdownSyntaxCompartmentRef.current + if (markdownCompartment) { + effects.push(markdownCompartment.reconfigure(markdownEditingExtensions())) + } + if (markdownSyntaxCompartment) { + effects.push( + markdownSyntaxCompartment.reconfigure(markdownSyntaxHighlightExtensions()) + ) + } + effects.push(comp.reconfigure(livePreviewPlugin)) + view.dispatch({ effects }) + } + return + } view.dispatch({ effects: comp.reconfigure(livePreview ? livePreviewPlugin : []) }) }, [livePreview]) useEffect(() => { @@ -1136,7 +1365,15 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { if (!view) return const raf = requestAnimationFrame(() => view.requestMeasure()) return () => cancelAnimationFrame(raf) - }, [editorFontSize, editorLineHeight, lineNumberMode, textFont, mode, connectionsOpen]) + }, [ + editorFontSize, + editorLineHeight, + lineNumberMode, + textFont, + mode, + connectionsOpen, + content?.path + ]) // Scroll sync between editor + preview when split mode is on. useEffect(() => { @@ -2051,8 +2288,59 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { const showPreview = !!content && mode !== 'edit' const splitMode = mode === 'split' const hasTabs = !zenMode && tabsEnabled && tabs.length > 0 + const tabStripMeasureKey = useMemo( + () => + tabItems + .map((tab) => `${tab.path}\u0000${tab.title}\u0000${tab.pinned ? '1' : '0'}`) + .join('\u0001'), + [tabItems] + ) + + useLayoutEffect(() => { + if (!hasTabs || wrapTabs) { + setTabStripOverflowing(false) + return + } + + const el = tabStripRef.current + if (!el) return + + const measure = (): void => { + const next = isTabStripOverflowing(el) + setTabStripOverflowing((current) => (current === next ? current : next)) + } + + measure() + + const observer = + typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(measure) + if (observer) { + observer.observe(el) + for (const child of Array.from(el.children)) { + observer.observe(child) + } + } + window.addEventListener('resize', measure) + + return () => { + observer?.disconnect() + window.removeEventListener('resize', measure) + } + }, [hasTabs, wrapTabs, tabStripMeasureKey]) + + const tabStripClass = [ + 'workspace-tab-strip glass-header flex shrink-0 items-start gap-1 border-b border-paper-300/70 px-3 pt-2', + wrapTabs + ? 'min-h-10 flex-wrap content-start overflow-x-hidden overflow-y-visible' + : `${tabStripOverflowing ? 'h-14 overflow-x-auto' : 'h-10 overflow-x-hidden'} overflow-y-hidden` + ].join(' ') + const editorReady = + !content || + !showEditor || + (editorHydration?.path === content.path && editorHydration.ready) + const previewSourceMarkdown = showPreview ? content?.body ?? '' : '' const { settledMarkdown: previewMarkdown } = useSettledMarkdown( - content?.body ?? '', + previewSourceMarkdown, splitMode ? 75 : 0 ) @@ -2146,7 +2434,8 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { > {hasTabs && (
@@ -2259,19 +2548,25 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { hasSelection: !sel.empty }) }} - > - {imageDropIndicatorTop != null && ( + > + {imageDropIndicatorTop != null && (
+
-
- )} -
-
+ )} + {editorReady ? ( +
+ ) : ( +
+ Preparing editor… +
+ )} +
{showPreview && (
+ +const NoteHoverPreviewImpl = lazy(() => + import('./NoteHoverPreview').then((mod) => ({ default: mod.NoteHoverPreview })) +) + +export function LazyNoteHoverPreview({ + note, + anchorRect, + placement = 'anchored', + interactive = false, + onPointerEnter, + onPointerLeave +}: { + note: Pick + anchorRect: AnchorRectLike + placement?: 'anchored' | 'floating' + interactive?: boolean + onPointerEnter?: () => void + onPointerLeave?: () => void +}): JSX.Element { + return ( + + + + ) +} diff --git a/packages/app-core/src/components/LazyPreview.tsx b/packages/app-core/src/components/LazyPreview.tsx new file mode 100644 index 00000000..878e4a1b --- /dev/null +++ b/packages/app-core/src/components/LazyPreview.tsx @@ -0,0 +1,28 @@ +import { lazy, Suspense } from 'react' + +const PreviewImpl = lazy(() => + import('./Preview').then((mod) => ({ default: mod.Preview })) +) + +export function LazyPreview({ + markdown, + notePath, + onRequestEdit, + onRendered +}: { + markdown: string + notePath: string + onRequestEdit?: (() => void) | null + onRendered?: (() => void) | null +}): JSX.Element { + return ( + + + + ) +} diff --git a/packages/app-core/src/components/NoteList.tsx b/packages/app-core/src/components/NoteList.tsx index c9fc7710..5280a405 100644 --- a/packages/app-core/src/components/NoteList.tsx +++ b/packages/app-core/src/components/NoteList.tsx @@ -14,7 +14,7 @@ import { confirmMoveToTrash } from '../lib/confirm-trash' import { buildMoveNotePrompt, parseMoveNoteTarget } from '../lib/move-note' import { extractTags } from '../lib/tags' import { setDragPayload } from '../lib/dnd' -import { usePrompt } from './PromptModal' +import { promptApp } from '../lib/prompt-requests' import { resolveSystemFolderLabels } from '../lib/system-folder-labels' import { assetBelongsToFolderView, @@ -22,11 +22,7 @@ import { noteBelongsToFolderView } from '../lib/vault-layout' import { assetTabPath } from '../lib/asset-tabs' - -function escapeForAttr(value: string): string { - if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') return CSS.escape(value) - return value.replace(/["\\]/g, '\\$&') -} +import { getScrollTopForVirtualIndex, getVirtualRange } from '../lib/virtual-list' function formatDate(ms: number): string { const d = new Date(ms) @@ -50,6 +46,16 @@ function formatBytes(bytes: number): string { const ASSET_LAYOUT_KEY = 'zen:assets-layout:v1' type AssetLayout = 'grid' | 'list' +type FolderEntry = { type: 'note'; note: NoteMeta } | { type: 'asset'; asset: AssetMeta } + +const VIRTUAL_OVERSCAN_ROWS = 8 +const FOLDER_ENTRY_ROW_HEIGHT = 76 +const ASSET_LIST_ROW_HEIGHT = 64 +const ASSET_GRID_ROW_HEIGHT = 166 + +function folderEntryPath(entry: FolderEntry): string { + return entry.type === 'note' ? entry.note.path : entry.asset.path +} export function NoteList(): JSX.Element { const vault = useStore((s) => s.vault) @@ -71,12 +77,12 @@ export function NoteList(): JSX.Element { const moveNote = useStore((s) => s.moveNote) const tabsEnabled = useStore((s) => s.tabsEnabled) const openNoteInTab = useStore((s) => s.openNoteInTab) + const prefetchNotes = useStore((s) => s.prefetchNotes) const focusedPanel = useStore((s) => s.focusedPanel) const noteListCursorIndex = useStore((s) => s.noteListCursorIndex) const setFocusedPanel = useStore((s) => s.setFocusedPanel) const systemFolderLabels = useStore((s) => s.systemFolderLabels) const workspaceMode = useStore((s) => s.workspaceMode) - const { prompt, modal: promptModal } = usePrompt() const canRevealInFileManager = window.zen.getAppInfo().runtime === 'desktop' && workspaceMode !== 'remote' const absolutePathLabel = @@ -95,6 +101,9 @@ export function NoteList(): JSX.Element { return 'grid' } }) + const scrollRef = useRef(null) + const [listScrollTop, setListScrollTop] = useState(0) + const [listViewportHeight, setListViewportHeight] = useState(0) useEffect(() => { try { @@ -103,6 +112,23 @@ export function NoteList(): JSX.Element { /* ignore */ } }, [assetLayout]) + + useEffect(() => { + const node = scrollRef.current + if (!node) return + + const updateSize = (): void => { + setListViewportHeight(node.clientHeight) + setListScrollTop(node.scrollTop) + } + + updateSize() + if (typeof ResizeObserver === 'undefined') return + + const observer = new ResizeObserver(updateSize) + observer.observe(node) + return () => observer.disconnect() + }, []) const emptyTrash = async (): Promise => { await window.zen.emptyTrash() await useStore.getState().refreshNotes() @@ -143,7 +169,7 @@ export function NoteList(): JSX.Element { if (selectedPath === n.path) await selectNote(null) } const onMove = async (): Promise => { - const target = await prompt(buildMoveNotePrompt(n, folders)) + const target = await promptApp(buildMoveNotePrompt(n, folders)) if (!target) return const dest = parseMoveNoteTarget(target) await moveNote(n.path, dest.folder, dest.subpath) @@ -174,7 +200,7 @@ export function NoteList(): JSX.Element { items.push({ label: 'Rename…', onSelect: async () => { - const next = await prompt({ + const next = await promptApp({ title: 'Rename note', initialValue: n.title, okLabel: 'Rename', @@ -417,9 +443,7 @@ export function NoteList(): JSX.Element { return result }, [filtered, viewKey, sortComparator, noteSortOrder]) - const orderedFolderEntries = useMemo< - Array<{ type: 'note'; note: NoteMeta } | { type: 'asset'; asset: AssetMeta }> - >(() => { + const orderedFolderEntries = useMemo(() => { if (view.kind === 'assets') { return filteredAssets.map((asset) => ({ type: 'asset' as const, asset })) } @@ -450,6 +474,80 @@ export function NoteList(): JSX.Element { ] }, [assetSortComparator, filteredAssets, noteSortOrder, orderedFiltered, view.kind]) + const notePrefetchPaths = useMemo(() => { + const paths = orderedFolderEntries + .filter((entry): entry is { type: 'note'; note: NoteMeta } => entry.type === 'note') + .map((entry) => entry.note.path) + if (paths.length === 0) return [] + const selectedIndex = selectedPath ? paths.indexOf(selectedPath) : -1 + const start = selectedIndex >= 0 ? selectedIndex : 0 + return paths.slice(start, start + 12) + }, [orderedFolderEntries, selectedPath]) + + useEffect(() => { + if (notePrefetchPaths.length === 0) return + const timer = window.setTimeout(() => prefetchNotes(notePrefetchPaths), 80) + return () => window.clearTimeout(timer) + }, [notePrefetchPaths, prefetchNotes]) + + const folderEntryRange = useMemo( + () => + getVirtualRange({ + itemCount: orderedFolderEntries.length, + itemSize: FOLDER_ENTRY_ROW_HEIGHT, + scrollTop: listScrollTop, + viewportHeight: listViewportHeight, + overscan: VIRTUAL_OVERSCAN_ROWS + }), + [listScrollTop, listViewportHeight, orderedFolderEntries.length] + ) + const visibleFolderEntries = useMemo( + () => orderedFolderEntries.slice(folderEntryRange.start, folderEntryRange.end), + [folderEntryRange.end, folderEntryRange.start, orderedFolderEntries] + ) + const selectedEntryIndex = useMemo(() => { + if (!selectedPath) return -1 + return orderedFolderEntries.findIndex((entry) => folderEntryPath(entry) === selectedPath) + }, [orderedFolderEntries, selectedPath]) + + const assetListRange = useMemo( + () => + getVirtualRange({ + itemCount: assetFiles.length, + itemSize: ASSET_LIST_ROW_HEIGHT, + scrollTop: listScrollTop, + viewportHeight: listViewportHeight, + overscan: VIRTUAL_OVERSCAN_ROWS + }), + [assetFiles.length, listScrollTop, listViewportHeight] + ) + const visibleAssetRows = useMemo( + () => assetFiles.slice(assetListRange.start, assetListRange.end), + [assetFiles, assetListRange.end, assetListRange.start] + ) + const assetGridRows = useMemo(() => { + const rows: AssetMeta[][] = [] + for (let index = 0; index < assetFiles.length; index += 2) { + rows.push(assetFiles.slice(index, index + 2)) + } + return rows + }, [assetFiles]) + const assetGridRange = useMemo( + () => + getVirtualRange({ + itemCount: assetGridRows.length, + itemSize: ASSET_GRID_ROW_HEIGHT, + scrollTop: listScrollTop, + viewportHeight: listViewportHeight, + overscan: VIRTUAL_OVERSCAN_ROWS + }), + [assetGridRows.length, listScrollTop, listViewportHeight] + ) + const visibleAssetGridRows = useMemo( + () => assetGridRows.slice(assetGridRange.start, assetGridRange.end), + [assetGridRange.end, assetGridRange.start, assetGridRows] + ) + const heading = view.kind === 'assets' ? 'Files' @@ -466,6 +564,22 @@ export function NoteList(): JSX.Element { const isNoteListFocused = focusedPanel === 'notelist' + const scrollFolderIndexIntoView = (index: number): void => { + const node = scrollRef.current + if (!node || index < 0 || orderedFolderEntries.length === 0) return + const nextScrollTop = getScrollTopForVirtualIndex({ + index, + itemCount: orderedFolderEntries.length, + itemSize: FOLDER_ENTRY_ROW_HEIGHT, + currentScrollTop: node.scrollTop, + viewportHeight: node.clientHeight + }) + if (nextScrollTop !== node.scrollTop) { + node.scrollTop = nextScrollTop + setListScrollTop(nextScrollTop) + } + } + useEffect(() => { if (!isNoteListFocused) return const next = @@ -478,21 +592,15 @@ export function NoteList(): JSX.Element { }, [isNoteListFocused, noteListCursorIndex, orderedFolderEntries.length]) useEffect(() => { - if (!isNoteListFocused || !selectedPath) return - const target = document.querySelector( - `[data-notelist-path="${escapeForAttr(selectedPath)}"]` - ) as HTMLElement | null - if (!target) return - - const idx = Number(target.dataset.notelistIdx) - if (Number.isFinite(idx) && idx !== noteListCursorIndex) { - useStore.getState().setNoteListCursorIndex(idx) - } + if (!isNoteListFocused || selectedEntryIndex < 0) return + useStore.getState().setNoteListCursorIndex(selectedEntryIndex) + scrollFolderIndexIntoView(selectedEntryIndex) + }, [isNoteListFocused, selectedEntryIndex]) - requestAnimationFrame(() => { - target.scrollIntoView({ block: 'nearest' }) - }) - }, [isNoteListFocused, selectedPath, noteListCursorIndex]) + useEffect(() => { + if (!isNoteListFocused || noteListCursorIndex < 0 || orderedFolderEntries.length === 0) return + scrollFolderIndexIntoView(Math.min(noteListCursorIndex, orderedFolderEntries.length - 1)) + }, [isNoteListFocused, noteListCursorIndex, orderedFolderEntries.length, listViewportHeight]) return (
-
+
setListScrollTop(event.currentTarget.scrollTop)} + > {view.kind === 'assets' ? ( assetFiles.length === 0 ? (
No files yet. Files anywhere inside the vault show up here.
) : assetLayout === 'grid' ? ( -
- {assetFiles.map((asset) => ( - void openNoteInTab(assetTabPath(asset.path))} - onContextMenu={(e) => { - e.preventDefault() - setAssetMenu({ x: e.clientX, y: e.clientY, path: asset.path }) +
+ {visibleAssetGridRows.map((row, offset) => ( +
+ > + {row.map((asset) => ( + void openNoteInTab(assetTabPath(asset.path))} + onContextMenu={(e) => { + e.preventDefault() + setAssetMenu({ x: e.clientX, y: e.clientY, path: asset.path }) + }} + /> + ))} +
))}
) : ( -
- {assetFiles.map((asset) => ( - + {visibleAssetRows.map((asset, offset) => ( +
void openNoteInTab(assetTabPath(asset.path))} - onContextMenu={(e) => { - e.preventDefault() - setAssetMenu({ x: e.clientX, y: e.clientY, path: asset.path }) + className="absolute inset-x-0" + style={{ + height: ASSET_LIST_ROW_HEIGHT, + transform: `translateY(${(assetListRange.start + offset) * ASSET_LIST_ROW_HEIGHT}px)` }} - /> + > + void openNoteInTab(assetTabPath(asset.path))} + onContextMenu={(e) => { + e.preventDefault() + setAssetMenu({ x: e.clientX, y: e.clientY, path: asset.path }) + }} + /> +
))}
) @@ -597,35 +729,48 @@ export function NoteList(): JSX.Element { : 'No files here yet.'}
) : ( - orderedFolderEntries.map((entry, i) => - entry.type === 'note' ? ( - void selectNote(entry.note.path)} - onContextMenu={(e) => { - e.preventDefault() - setMenu({ x: e.clientX, y: e.clientY, path: entry.note.path }) - }} - noteListIdx={i} - vimHighlight={isNoteListFocused && noteListCursorIndex === i} - /> - ) : ( - void openNoteInTab(assetTabPath(entry.asset.path))} - onContextMenu={(e) => { - e.preventDefault() - setAssetMenu({ x: e.clientX, y: e.clientY, path: entry.asset.path }) - }} - noteListIdx={i} - vimHighlight={isNoteListFocused && noteListCursorIndex === i} - /> - ), - ) +
+ {visibleFolderEntries.map((entry, offset) => { + const i = folderEntryRange.start + offset + const path = folderEntryPath(entry) + return ( +
+ {entry.type === 'note' ? ( + void selectNote(entry.note.path)} + onContextMenu={(e) => { + e.preventDefault() + setMenu({ x: e.clientX, y: e.clientY, path: entry.note.path }) + }} + noteListIdx={i} + vimHighlight={isNoteListFocused && noteListCursorIndex === i} + /> + ) : ( + void openNoteInTab(assetTabPath(entry.asset.path))} + onContextMenu={(e) => { + e.preventDefault() + setAssetMenu({ x: e.clientX, y: e.clientY, path: entry.asset.path }) + }} + noteListIdx={i} + vimHighlight={isNoteListFocused && noteListCursorIndex === i} + /> + )} +
+ ) + })} +
)}
{menu && ( @@ -644,7 +789,6 @@ export function NoteList(): JSX.Element { onClose={() => setAssetMenu(null)} /> )} - {promptModal} noteListWidth} @@ -679,7 +823,7 @@ function NoteRow({ draggable onDragStart={(e) => setDragPayload(e, { kind: 'note', path: note.path })} className={[ - 'list-row mb-1 flex w-full flex-col gap-1 rounded-lg px-3 py-2 text-left outline-none focus:outline-none', + 'list-row flex h-[72px] w-full flex-col gap-1 rounded-lg px-3 py-2 text-left outline-none focus:outline-none', active ? `${vimHighlight ? 'vim-cursor-on-selected ' : ''}bg-paper-200` : vimHighlight @@ -737,7 +881,7 @@ function FolderAssetRow({ onClick={onOpen} onContextMenu={onContextMenu} className={[ - 'list-row mb-1 flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left outline-none focus:outline-none', + 'list-row flex h-[72px] w-full items-center gap-3 rounded-lg px-3 py-2 text-left outline-none focus:outline-none', vimHighlight ? 'vim-cursor' : 'hover:bg-paper-200/60' ].join(' ')} style={vimHighlight ? { boxShadow: 'inset 0 0 0 1px rgb(var(--z-accent) / 0.35)' } : undefined} @@ -796,7 +940,7 @@ function AssetCard({ type="button" onClick={onOpen} onContextMenu={onContextMenu} - className="flex min-h-[154px] flex-col overflow-hidden rounded-xl border border-paper-300/70 bg-paper-50/24 text-left transition-colors hover:border-paper-400 hover:bg-paper-100/40" + className="flex h-full min-h-[154px] flex-col overflow-hidden rounded-xl border border-paper-300/70 bg-paper-50/24 text-left transition-colors hover:border-paper-400 hover:bg-paper-100/40" >
{asset.kind === 'image' && url ? ( @@ -841,7 +985,7 @@ function AssetRow({ type="button" onClick={onOpen} onContextMenu={onContextMenu} - className="flex items-center gap-3 rounded-lg border border-transparent px-3 py-2 text-left transition-colors hover:border-paper-300/70 hover:bg-paper-200/45" + className="flex h-[60px] items-center gap-3 rounded-lg border border-transparent px-3 py-2 text-left transition-colors hover:border-paper-300/70 hover:bg-paper-200/45" >
{asset.kind === 'image' && url ? ( diff --git a/packages/app-core/src/components/PinnedReferencePane.tsx b/packages/app-core/src/components/PinnedReferencePane.tsx index 71c5b318..3e1358e3 100644 --- a/packages/app-core/src/components/PinnedReferencePane.tsx +++ b/packages/app-core/src/components/PinnedReferencePane.tsx @@ -42,7 +42,7 @@ import { slashCommandSource, slashCommandRender } from '../lib/cm-slash-commands import { dateShortcutSource } from '../lib/cm-date-shortcuts' import { wikilinkSource } from '../lib/cm-wikilinks' import { classifyLocalAssetHref, type LocalAssetKind } from '../lib/local-assets' -import { Preview } from './Preview' +import { LazyPreview as Preview } from './LazyPreview' import { CloseIcon, PanelLeftIcon, PinIcon } from './icons' const PINNED_REF_PANE_ID = 'pinned-ref' diff --git a/packages/app-core/src/components/PromptHost.tsx b/packages/app-core/src/components/PromptHost.tsx index 9e0e46ae..043429d7 100644 --- a/packages/app-core/src/components/PromptHost.tsx +++ b/packages/app-core/src/components/PromptHost.tsx @@ -1,54 +1,32 @@ -import { useEffect, useState } from 'react' -import { PromptModal, type PromptOptions } from './PromptModal' - -type PromptRequest = { - options: PromptOptions - resolve: (value: string | null) => void -} - -let currentRequest: PromptRequest | null = null -const listeners = new Set<(request: PromptRequest | null) => void>() - -function emit(): void { - for (const listener of listeners) listener(currentRequest) -} - -export function promptApp(options: PromptOptions): Promise { - return new Promise((resolve) => { - currentRequest = { options, resolve } - emit() - }) -} +import { lazy, Suspense, useEffect, useState } from 'react' +import { + getPromptRequest, + settlePromptRequest, + subscribePromptRequests, + type PromptRequest +} from '../lib/prompt-requests' + +const PromptModal = lazy(async () => { + const module = await import('./PromptModal') + return { default: module.PromptModal } +}) export function PromptHost(): JSX.Element | null { - const [request, setRequest] = useState(currentRequest) + const [request, setRequest] = useState(getPromptRequest) useEffect(() => { - listeners.add(setRequest) - return () => { - listeners.delete(setRequest) - } + return subscribePromptRequests(setRequest) }, []) if (!request) return null return ( - { - const resolve = request.resolve - currentRequest = null - setRequest(null) - queueMicrotask(() => resolve(value)) - emit() - }} - onCancel={() => { - const resolve = request.resolve - currentRequest = null - setRequest(null) - queueMicrotask(() => resolve(null)) - emit() - }} - /> + + settlePromptRequest(request, value)} + onCancel={() => settlePromptRequest(request, null)} + /> + ) } diff --git a/packages/app-core/src/components/PromptModal.tsx b/packages/app-core/src/components/PromptModal.tsx index fecdcb1a..c391f129 100644 --- a/packages/app-core/src/components/PromptModal.tsx +++ b/packages/app-core/src/components/PromptModal.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' export interface PromptSuggestion { @@ -253,44 +253,3 @@ export function PromptModal({ document.body ) } - -/** - * Hook that returns a `prompt(opts)` function returning a Promise - * resolving to the string the user entered (or `null` if cancelled), - * along with the modal element to render somewhere in the tree. - */ -export function usePrompt(): { - prompt: (options: PromptOptions) => Promise - modal: JSX.Element | null -} { - const [state, setState] = useState<{ - options: PromptOptions - resolve: (v: string | null) => void - } | null>(null) - - const prompt = useCallback( - (options: PromptOptions) => - new Promise((resolve) => { - setState({ options, resolve }) - }), - [] - ) - - const modal = state ? ( - { - const resolve = state.resolve - setState(null) - queueMicrotask(() => resolve(v)) - }} - onCancel={() => { - const resolve = state.resolve - setState(null) - queueMicrotask(() => resolve(null)) - }} - /> - ) : null - - return { prompt, modal } -} diff --git a/packages/app-core/src/components/QuickCaptureApp.tsx b/packages/app-core/src/components/QuickCaptureApp.tsx index d9cbff3d..c2fa62ef 100644 --- a/packages/app-core/src/components/QuickCaptureApp.tsx +++ b/packages/app-core/src/components/QuickCaptureApp.tsx @@ -24,7 +24,6 @@ * :find — open the note picker (alias for ⌘P). */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import Fuse from 'fuse.js' import { Compartment, EditorState, type Transaction } from '@codemirror/state' import { EditorView, @@ -49,6 +48,10 @@ import { type ThemeFamily, type ThemeMode } from '../lib/themes' +import { + buildNoteSearchIndex, + searchNoteIndex +} from '../lib/note-search' const PREFS_KEY = 'zen:prefs:v2' @@ -613,56 +616,14 @@ function NotePickerOverlay({ notes, onPick, onCancel }: NotePickerOverlayProps): inputRef.current?.focus() }, []) - const fuse = useMemo( - () => - new Fuse(notes, { - keys: [ - { name: 'title', weight: 0.7 }, - { name: 'path', weight: 0.2 }, - { name: 'tags', weight: 0.1 } - ], - threshold: 0.35, - ignoreLocation: true - }), - [notes] - ) - - const { freeText, tagTokens } = useMemo(() => { - const tags: string[] = [] - const text: string[] = [] - for (const tok of query.split(/\s+/)) { - if (!tok) continue - if (tok.startsWith('#') && tok.length > 1) tags.push(tok.slice(1).toLowerCase()) - else text.push(tok) - } - return { freeText: text.join(' ').trim(), tagTokens: tags } - }, [query]) + const searchIndex = useMemo(() => buildNoteSearchIndex(notes), [notes]) const results = useMemo(() => { - const byTag = (n: NoteMeta): boolean => { - if (tagTokens.length === 0) return true - const tagsLower = n.tags.map((t) => t.toLowerCase()) - return tagTokens.every((t) => tagsLower.includes(t)) - } - const live = notes.filter((n) => n.folder !== 'trash' && byTag(n)) - if (!freeText) { - // Default sort: most recently updated first, with Quick first - // since the capture surface is biased toward Quick by design. - return [...live] - .sort((a, b) => { - if (a.folder === 'quick' && b.folder !== 'quick') return -1 - if (b.folder === 'quick' && a.folder !== 'quick') return 1 - return b.updatedAt - a.updatedAt - }) - .slice(0, 30) - } - const set = new Set(live.map((n) => n.path)) - return fuse - .search(freeText) - .map((r) => r.item) - .filter((n) => set.has(n.path)) - .slice(0, 30) - }, [fuse, freeText, tagTokens, notes]) + return searchNoteIndex(searchIndex, query, { + limit: 30, + defaultOrder: 'quick-first-recent' + }) + }, [query, searchIndex]) useEffect(() => setActive(0), [query]) diff --git a/packages/app-core/src/components/SearchPalette.tsx b/packages/app-core/src/components/SearchPalette.tsx index 13bb3816..9af7e598 100644 --- a/packages/app-core/src/components/SearchPalette.tsx +++ b/packages/app-core/src/components/SearchPalette.tsx @@ -1,8 +1,12 @@ import { useEffect, useMemo, useRef, useState } from 'react' -import Fuse from 'fuse.js' import { useStore } from '../store' import type { NoteMeta } from '@shared/ipc' import { isPaletteNextKey, isPalettePreviousKey } from '../lib/palette-nav' +import { + buildNoteSearchIndex, + parseNoteSearchQuery, + searchNoteIndex +} from '../lib/note-search' export function SearchPalette(): JSX.Element { const notes = useStore((s) => s.notes) @@ -14,58 +18,17 @@ export function SearchPalette(): JSX.Element { const inputRef = useRef(null) const listRef = useRef(null) - const fuse = useMemo( - () => - new Fuse(notes, { - keys: [ - { name: 'title', weight: 0.6 }, - { name: 'excerpt', weight: 0.3 }, - { name: 'tags', weight: 0.1 } - ], - threshold: 0.35, - ignoreLocation: true, - includeMatches: false - }), - [notes] - ) + const searchIndex = useMemo(() => buildNoteSearchIndex(notes), [notes]) // Strip `#tag` tokens off the query so the user can narrow by one or // more tags inline: `#ops #prod migration` means "notes tagged with // #ops AND #prod, fuzzy-matching 'migration'". Pure-tag queries (no // free text) still work — in that case we just list matching notes. - const { freeText, tagTokens } = useMemo(() => { - const rawTokens = query.split(/\s+/) - const tags: string[] = [] - const text: string[] = [] - for (const tok of rawTokens) { - if (!tok) continue - if (tok.startsWith('#') && tok.length > 1) { - tags.push(tok.slice(1).toLowerCase()) - } else { - text.push(tok) - } - } - return { freeText: text.join(' ').trim(), tagTokens: tags } - }, [query]) + const { tagTokens } = useMemo(() => parseNoteSearchQuery(query), [query]) const results = useMemo(() => { - const byTag = (n: NoteMeta): boolean => { - if (tagTokens.length === 0) return true - const tagsLower = n.tags.map((t) => t.toLowerCase()) - // AND semantics for search — every hashtag token must match. This - // narrows the result set the more tags you add, matching how users - // expect search filters to compose. - return tagTokens.every((t) => tagsLower.includes(t)) - } - const live = notes.filter((n) => n.folder !== 'trash' && byTag(n)) - if (!freeText) return live.slice(0, 20) - const set = new Set(live.map((n) => n.path)) - return fuse - .search(freeText) - .map((r) => r.item) - .filter((n) => set.has(n.path)) - .slice(0, 20) - }, [fuse, freeText, tagTokens, notes]) + return searchNoteIndex(searchIndex, query, { limit: 20 }) + }, [query, searchIndex]) useEffect(() => { inputRef.current?.focus() diff --git a/packages/app-core/src/components/ServerDirectoryPickerHost.tsx b/packages/app-core/src/components/ServerDirectoryPickerHost.tsx index e76a24e9..2a6b74b5 100644 --- a/packages/app-core/src/components/ServerDirectoryPickerHost.tsx +++ b/packages/app-core/src/components/ServerDirectoryPickerHost.tsx @@ -1,64 +1,37 @@ -import { useEffect, useState } from 'react' +import { lazy, Suspense, useEffect, useState } from 'react' import { - ServerDirectoryPickerModal, - type ServerDirectoryPickerOptions -} from './ServerDirectoryPickerModal' + getDirectoryPickerRequest, + settleDirectoryPickerRequest, + subscribeDirectoryPickerRequests, + type DirectoryPickerRequest +} from '../lib/server-directory-picker-requests' -type DirectoryPickerRequest = { - options: ServerDirectoryPickerOptions - onConfirm?: (path: string) => Promise | void - resolve: (value: string | null) => void -} - -let currentRequest: DirectoryPickerRequest | null = null -const listeners = new Set<(request: DirectoryPickerRequest | null) => void>() - -function emit(): void { - for (const listener of listeners) listener(currentRequest) -} - -export function pickServerDirectoryApp( - options: ServerDirectoryPickerOptions, - onConfirm?: (path: string) => Promise | void -): Promise { - return new Promise((resolve) => { - currentRequest = { options, onConfirm, resolve } - emit() - }) -} +const ServerDirectoryPickerModal = lazy(async () => { + const module = await import('./ServerDirectoryPickerModal') + return { default: module.ServerDirectoryPickerModal } +}) export function ServerDirectoryPickerHost(): JSX.Element | null { - const [request, setRequest] = useState(currentRequest) + const [request, setRequest] = useState(getDirectoryPickerRequest) useEffect(() => { - listeners.add(setRequest) - return () => { - listeners.delete(setRequest) - } + return subscribeDirectoryPickerRequests(setRequest) }, []) if (!request) return null return ( - { - if (request.onConfirm) { - await request.onConfirm(path) - } - const resolve = request.resolve - currentRequest = null - setRequest(null) - queueMicrotask(() => resolve(path)) - emit() - }} - onCancel={() => { - const resolve = request.resolve - currentRequest = null - setRequest(null) - queueMicrotask(() => resolve(null)) - emit() - }} - /> + + { + if (request.onConfirm) { + await request.onConfirm(path) + } + settleDirectoryPickerRequest(request, path) + }} + onCancel={() => settleDirectoryPickerRequest(request, null)} + /> + ) } diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index 227acd99..09352db0 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -43,7 +43,7 @@ import { } from '../lib/settings-search' import { getZenBridge } from '@zennotes/bridge-contract/bridge' import companyLogo from '../assets/lumary-labs-logo.svg' -import { confirmApp } from './ConfirmHost' +import { confirmApp } from '../lib/confirm-requests' import { RemoteWorkspaceProfileModal } from './RemoteWorkspaceProfileModal' type SettingsCategoryId = @@ -211,6 +211,8 @@ export function SettingsModal(): JSX.Element { const setLivePreview = useStore((s) => s.setLivePreview) const tabsEnabled = useStore((s) => s.tabsEnabled) const setTabsEnabled = useStore((s) => s.setTabsEnabled) + const wrapTabs = useStore((s) => s.wrapTabs) + const setWrapTabs = useStore((s) => s.setWrapTabs) const quickNoteDateTitle = useStore((s) => s.quickNoteDateTitle) const setQuickNoteDateTitle = useStore((s) => s.setQuickNoteDateTitle) const quickNoteTitlePrefix = useStore((s) => s.quickNoteTitlePrefix) @@ -835,6 +837,12 @@ export function SettingsModal(): JSX.Element { description: 'Open notes in tabs and allow split-friendly tab workflows.', keywords: ['tabs'] }, + { + id: 'wrap-note-tabs', + title: 'Wrap note tabs', + description: 'Move overflowing tabs onto additional rows instead of horizontal scrolling.', + keywords: ['tabs', 'wrap', 'new line', 'overflow'] + }, { id: 'word-wrap', title: 'Word wrap', @@ -996,6 +1004,13 @@ export function SettingsModal(): JSX.Element { settingId="note-tabs" onChange={setTabsEnabled} /> + rightSet.has(item)); +} + function selectionSetsEqual(a: Set, b: Set): boolean { if (a.size !== b.size) return false; for (const key of a) { @@ -295,6 +313,7 @@ export function Sidebar(): JSX.Element { const focusedPanel = useStore((s) => s.focusedPanel); const sidebarCursorIndex = useStore((s) => s.sidebarCursorIndex); const activeNote = useStore((s) => s.activeNote); + const activeDirty = useStore((s) => s.activeDirty); const vaultSettings = useStore((s) => s.vaultSettings); const view = useStore((s) => s.view); const assetFiles = useStore((s) => s.assetFiles); @@ -349,7 +368,6 @@ export function Sidebar(): JSX.Element { const moveNoteAction = useStore((s) => s.moveNote); const renameNote = useStore((s) => s.renameNote); const setVaultSettings = useStore((s) => s.setVaultSettings); - const { prompt, modal: promptModal } = usePrompt(); const canRevealInFileManager = window.zen.getAppInfo().runtime === "desktop" && workspaceMode !== "remote"; const absolutePathLabel = @@ -364,6 +382,12 @@ export function Sidebar(): JSX.Element { }, [openNoteInTab], ); + const handleSelectNote = useCallback( + (path: string): void => { + void selectNote(path); + }, + [selectNote], + ); const remoteLabel = useMemo( () => remoteWorkspaceLabel(remoteWorkspaceInfo?.baseUrl ?? null), [remoteWorkspaceInfo?.baseUrl], @@ -428,6 +452,20 @@ export function Sidebar(): JSX.Element { const selectedSidebarCount = selectedNoteMetas.length + selectedSidebarItems.filter((item) => item.kind === "folder").length; + const selectionAnchorKeyRef = useRef(selectionAnchorKey); + const selectedSidebarKeysRef = useRef(selectedSidebarKeys); + const selectedSidebarItemsRef = useRef(selectedSidebarItems); + const selectedSidebarCountRef = useRef(selectedSidebarCount); + + useEffect(() => { + selectionAnchorKeyRef.current = selectionAnchorKey; + }, [selectionAnchorKey]); + + useEffect(() => { + selectedSidebarKeysRef.current = selectedSidebarKeys; + selectedSidebarItemsRef.current = selectedSidebarItems; + selectedSidebarCountRef.current = selectedSidebarCount; + }, [selectedSidebarCount, selectedSidebarItems, selectedSidebarKeys]); useEffect(() => { const availableKeys = new Set(); @@ -474,7 +512,7 @@ export function Sidebar(): JSX.Element { const key = selectionKeyForItem(item); if (event.shiftKey) { event.preventDefault(); - const anchor = selectionAnchorKey ?? key; + const anchor = selectionAnchorKeyRef.current ?? key; setSelectedSidebarKeys(selectSidebarRange(anchor, key)); setSelectionAnchorKey(anchor); return; @@ -494,7 +532,7 @@ export function Sidebar(): JSX.Element { setSelectionAnchorKey(key); primaryAction(); }, - [selectSidebarRange, selectionAnchorKey], + [selectSidebarRange], ); const prepareContextSelection = useCallback( @@ -512,10 +550,12 @@ export function Sidebar(): JSX.Element { const dragPayloadForItem = useCallback( (item: SidebarSelectionItem): DragPayload => { const key = selectionKeyForItem(item); - if (selectedSidebarKeys.has(key) && selectedSidebarCount > 1) { + const selectedKeys = selectedSidebarKeysRef.current; + const selectedCount = selectedSidebarCountRef.current; + if (selectedKeys.has(key) && selectedCount > 1) { return { kind: "multi", - items: selectedSidebarItems.map((selected) => + items: selectedSidebarItemsRef.current.map((selected) => selected.kind === "note" ? { kind: "note", path: selected.path } : { @@ -530,7 +570,7 @@ export function Sidebar(): JSX.Element { ? { kind: "note", path: item.path } : { kind: "folder", folder: item.folder, subpath: item.subpath }; }, - [selectedSidebarCount, selectedSidebarItems, selectedSidebarKeys], + [], ); /** @@ -719,8 +759,9 @@ export function Sidebar(): JSX.Element { // the folders index from main so empty subfolders still appear in // the tree alongside ones that have notes. Trash is rendered // separately. - const trees = useMemo( - () => ({ + const trees = useMemo(() => { + const startedAt = performance.now(); + const next = { quick: buildTree( notes.filter((n) => n.folder === "quick"), assetFiles.filter( @@ -757,9 +798,14 @@ export function Sidebar(): JSX.Element { allFolders.filter((f) => f.folder === "trash"), vaultSettings, ), - }), - [notes, allFolders, assetFiles, vaultSettings], - ); + }; + recordRendererPerf("sidebar.tree-build", performance.now() - startedAt, { + notes: notes.length, + folders: allFolders.length, + assets: assetFiles.length, + }); + return next; + }, [notes, allFolders, assetFiles, vaultSettings]); const treeSortComparator = useMemo< ((a: NoteMeta, b: NoteMeta) => number) | null @@ -881,18 +927,88 @@ export function Sidebar(): JSX.Element { }; }, [autoReveal, activePath, selectedPath, setCollapsedFoldersAction]); - // Aggregate hashtags across non-trash notes, with the active note - // re-computed from its live body. + const [activeBodyTagSnapshot, setActiveBodyTagSnapshot] = useState<{ + path: string; + tags: string[]; + } | null>(null); + + useEffect(() => { + const path = activeNote?.path ?? null; + const body = activeNote?.body ?? null; + if (!path || body == null || !activeDirty) { + setActiveBodyTagSnapshot((current) => (current === null ? current : null)); + return; + } + + let cancelled = false; + let idleId: number | null = null; + const parse = (): void => { + idleId = null; + if (cancelled) return; + const startedAt = performance.now(); + const tags = extractTags(body); + if (cancelled) return; + const indexedTags = + useStore.getState().notes.find((note) => note.path === path)?.tags ?? []; + if (sameStringSet(tags, indexedTags)) { + setActiveBodyTagSnapshot((current) => + current?.path === path ? null : current, + ); + } else { + setActiveBodyTagSnapshot({ path, tags }); + } + recordRendererPerf("sidebar.active-tags", performance.now() - startedAt, { + path, + chars: body.length, + tags: tags.length, + }); + }; + + const delayMs = + body.length >= ACTIVE_TAG_PARSE_LARGE_BODY_CHARS + ? ACTIVE_TAG_PARSE_LARGE_BODY_DELAY_MS + : ACTIVE_TAG_PARSE_DELAY_MS; + const timeoutId = window.setTimeout(() => { + if (typeof window.requestIdleCallback === "function") { + idleId = window.requestIdleCallback(parse, { timeout: 1_000 }); + return; + } + parse(); + }, delayMs); + + return () => { + cancelled = true; + window.clearTimeout(timeoutId); + if ( + idleId != null && + typeof window.cancelIdleCallback === "function" + ) { + window.cancelIdleCallback(idleId); + } + }; + }, [activeDirty, activeNote?.body, activeNote?.path]); + + // Aggregate hashtags across non-trash notes. The active note's live + // body is parsed only while it has unsaved edits; clean notes use the + // indexed tags from note metadata and never reparse large bodies on open. const tags = useMemo(() => { + const startedAt = performance.now(); const counter = new Map(); + const liveActivePath = activeBodyTagSnapshot?.path ?? null; + const liveActiveTags = activeBodyTagSnapshot?.tags ?? null; for (const n of notes) { if (n.folder === "trash") continue; - const isActive = activeNote && activeNote.path === n.path; - const list = isActive ? extractTags(activeNote!.body) : n.tags; + const list = + liveActivePath === n.path && liveActiveTags ? liveActiveTags : n.tags; for (const t of list) counter.set(t, (counter.get(t) ?? 0) + 1); } - return [...counter.entries()].sort((a, b) => a[0].localeCompare(b[0])); - }, [notes, activeNote]); + const next = [...counter.entries()].sort((a, b) => a[0].localeCompare(b[0])); + recordRendererPerf("sidebar.tags", performance.now() - startedAt, { + notes: notes.length, + tags: next.length, + }); + return next; + }, [activeBodyTagSnapshot, notes]); const bulkSelectionMenuItems = useMemo(() => { if (selectedSidebarCount <= 1) return null; @@ -953,7 +1069,7 @@ export function Sidebar(): JSX.Element { items.push({ label: `Move ${liveNotes.length} note${liveNotes.length === 1 ? "" : "s"}…`, onSelect: async () => { - const target = await prompt( + const target = await promptApp( buildMoveNotePrompt( { title: `${liveNotes.length} notes`, path: liveNotes[0]!.path }, allFolders, @@ -1114,7 +1230,6 @@ export function Sidebar(): JSX.Element { folderLabels.trash, openNoteInTab, primaryNotesAtRoot, - prompt, refreshNotes, renameFolderAction, selectNote, @@ -1214,7 +1329,7 @@ export function Sidebar(): JSX.Element { items.push({ label: "New folder", onSelect: async () => { - const name = await prompt({ + const name = await promptApp({ title: `New folder inside "${label}"`, placeholder: "Folder name", okLabel: "Create", @@ -1304,7 +1419,7 @@ export function Sidebar(): JSX.Element { label: "Rename…", onSelect: async () => { const leaf = subpath.split("/").slice(-1)[0]; - const next = await prompt({ + const next = await promptApp({ title: "Rename folder", initialValue: leaf, okLabel: "Rename", @@ -1363,7 +1478,6 @@ export function Sidebar(): JSX.Element { refreshNotes, selectedPath, selectNote, - prompt, vaultSettings.folderIcons, vaultSettings, primaryNotesAtRoot, @@ -1403,7 +1517,7 @@ export function Sidebar(): JSX.Element { items.push({ label: "Rename…", onSelect: async () => { - const next = await prompt({ + const next = await promptApp({ title: "Rename note", initialValue: n.title, okLabel: "Rename", @@ -1419,7 +1533,7 @@ export function Sidebar(): JSX.Element { items.push({ label: "Move…", onSelect: async () => { - const target = await prompt(buildMoveNotePrompt(n, allFolders)); + const target = await promptApp(buildMoveNotePrompt(n, allFolders)); if (!target) return; const dest = parseMoveNoteTarget(target); await moveNoteAction(n.path, dest.folder, dest.subpath); @@ -1545,7 +1659,6 @@ export function Sidebar(): JSX.Element { selectNote, selectedPath, refreshNotes, - prompt, renameNote, moveNoteAction, canRevealInFileManager, @@ -1616,7 +1729,7 @@ export function Sidebar(): JSX.Element { { label: "Rename tag…", onSelect: async () => { - const next = await prompt({ + const next = await promptApp({ title: `Rename #${tag}`, initialValue: tag, okLabel: "Rename", @@ -1650,7 +1763,7 @@ export function Sidebar(): JSX.Element { }, }, ]; - }, [tagMenu, renameTag, deleteTag, prompt]); + }, [tagMenu, renameTag, deleteTag]); // A folder only shows the strong "selected" accent highlight when // the view matches AND no specific note is selected. Once the user @@ -1663,26 +1776,40 @@ export function Sidebar(): JSX.Element { view.folder === folder && view.subpath === subpath); - const openFolderMenu = ( - e: React.MouseEvent, - folder: NoteFolder, - subpath: string, - ): void => { - e.preventDefault(); - if (subpath) { - prepareContextSelection({ kind: "folder", folder, subpath }); - } else { - setSelectedSidebarKeys(new Set()); - setSelectionAnchorKey(null); - } - setFolderMenu({ x: e.clientX, y: e.clientY, folder, subpath }); - }; + const openFolderMenu = useCallback( + ( + e: React.MouseEvent, + folder: NoteFolder, + subpath: string, + ): void => { + e.preventDefault(); + if (subpath) { + prepareContextSelection({ kind: "folder", folder, subpath }); + } else { + setSelectedSidebarKeys(new Set()); + setSelectionAnchorKey(null); + } + setFolderMenu({ x: e.clientX, y: e.clientY, folder, subpath }); + }, + [prepareContextSelection], + ); - const openNoteMenu = (e: React.MouseEvent, note: NoteMeta): void => { - e.preventDefault(); - prepareContextSelection({ kind: "note", path: note.path }); - setNoteMenu({ x: e.clientX, y: e.clientY, path: note.path }); - }; + const openNoteMenu = useCallback( + (e: React.MouseEvent, note: NoteMeta): void => { + e.preventDefault(); + prepareContextSelection({ kind: "note", path: note.path }); + setNoteMenu({ x: e.clientX, y: e.clientY, path: note.path }); + }, + [prepareContextSelection], + ); + + const openAssetMenu = useCallback( + (e: React.MouseEvent, asset: AssetMeta): void => { + e.preventDefault(); + setAssetMenu({ x: e.clientX, y: e.clientY, path: asset.path }); + }, + [], + ); const isSidebarFocused = focusedPanel === "sidebar"; // Mutable counter reset on each render — assigns sequential data-sidebar-idx to each item. @@ -1899,7 +2026,7 @@ export function Sidebar(): JSX.Element { view.kind === "folder" && !noFolders ? view.folder : "inbox"; const parentSub = view.kind === "folder" && !noFolders ? view.subpath : ""; - const name = await prompt({ + const name = await promptApp({ title: "New folder", placeholder: "Folder name", okLabel: "Create", @@ -1981,13 +2108,10 @@ export function Sidebar(): JSX.Element { showNotes={unifiedSidebar} selectedPath={selectedPath} vaultRoot={vault?.root ?? null} - onSelectNote={(p) => void selectNote(p)} + onSelectNote={handleSelectNote} onOpenAsset={openAssetInTab} onNoteContextMenu={openNoteMenu} - onAssetContextMenu={(e, asset) => { - e.preventDefault(); - setAssetMenu({ x: e.clientX, y: e.clientY, path: asset.path }); - }} + onAssetContextMenu={openAssetMenu} sortComparator={treeSortComparator} onDropOnFolder={handleDropOnFolder} selectedKeys={selectedSidebarKeys} @@ -2079,13 +2203,10 @@ export function Sidebar(): JSX.Element { showNotes={unifiedSidebar} selectedPath={selectedPath} vaultRoot={vault?.root ?? null} - onSelectNote={(p) => void selectNote(p)} + onSelectNote={handleSelectNote} onOpenAsset={openAssetInTab} onNoteContextMenu={openNoteMenu} - onAssetContextMenu={(e, asset) => { - e.preventDefault(); - setAssetMenu({ x: e.clientX, y: e.clientY, path: asset.path }); - }} + onAssetContextMenu={openAssetMenu} sortComparator={treeSortComparator} onDropOnFolder={handleDropOnFolder} selectedKeys={selectedSidebarKeys} @@ -2115,13 +2236,10 @@ export function Sidebar(): JSX.Element { showNotes={unifiedSidebar} selectedPath={selectedPath} vaultRoot={vault?.root ?? null} - onSelectNote={(p) => void selectNote(p)} + onSelectNote={handleSelectNote} onOpenAsset={openAssetInTab} onNoteContextMenu={openNoteMenu} - onAssetContextMenu={(e, asset) => { - e.preventDefault(); - setAssetMenu({ x: e.clientX, y: e.clientY, path: asset.path }); - }} + onAssetContextMenu={openAssetMenu} sortComparator={treeSortComparator} onDropOnFolder={handleDropOnFolder} selectedKeys={selectedSidebarKeys} @@ -2312,7 +2430,6 @@ export function Sidebar(): JSX.Element { onCancel={() => setFolderIconPicker(null)} /> )} - {promptModal} {sortMenu && ( SIDEBAR_PROGRESSIVE_RENDER_THRESHOLD && + entries.every((entry) => entry.type !== "folder") + ); +} + +function treeRenderEntryPath(entry: TreeRenderEntry): string | null { + if (entry.type === "note") return entry.note.path; + if (entry.type === "asset") return entry.asset.path; + return null; +} + +function sidebarVisiblePrefetchPaths(entries: TreeRenderEntry[]): string[] { + return getSidebarEdgePrefetchPaths( + entries.map((entry) => (entry.type === "note" ? entry.note.path : null)), + ); +} + +function prefetchSidebarEdgeNotes(entries: TreeRenderEntry[], enabled: boolean): void { + if (!enabled || entries.length === 0) return; + const progressive = shouldProgressivelyRenderEntries(entries); + const limit = getInitialSidebarEntryLimit(entries.length, progressive); + const visibleEntries = progressive ? entries.slice(0, limit) : entries; + const paths = sidebarVisiblePrefetchPaths(visibleEntries); + if (paths.length === 0) return; + void useStore.getState().prefetchNotes(paths); +} + +function useSidebarVisibleNotePrefetch( + entries: TreeRenderEntry[], + enabled: boolean, +): void { + const paths = useMemo( + () => (enabled ? sidebarVisiblePrefetchPaths(entries) : []), + [enabled, entries], + ); + + useLayoutEffect(() => { + if (paths.length === 0) return; + void useStore.getState().prefetchNotes(paths); + }, [paths]); +} + +function useProgressiveEntryLimit( + total: number, + enabled: boolean, +): [number, (node: HTMLDivElement | null) => void] { + const initial = getInitialSidebarEntryLimit(total, enabled); + const [limit, setLimit] = useState(initial); + const observerRef = useRef(null); + + useEffect(() => { + setLimit(getInitialSidebarEntryLimit(total, enabled)); + }, [enabled, total]); + + useEffect(() => { + return () => { + observerRef.current?.disconnect(); + observerRef.current = null; + }; + }, []); + + const setSentinelRef = useCallback( + (node: HTMLDivElement | null): void => { + observerRef.current?.disconnect(); + observerRef.current = null; + + if (!enabled || !node || limit >= total) return; + if (typeof IntersectionObserver === "undefined") return; + + const observer = new IntersectionObserver( + (entries) => { + if (!entries.some((entry) => entry.isIntersecting)) return; + setLimit((current) => getNextSidebarEntryLimit(current, total)); + }, + { + root: null, + rootMargin: `${SIDEBAR_PROGRESSIVE_SENTINEL_MARGIN_PX}px 0px`, + }, + ); + observer.observe(node); + observerRef.current = observer; + }, + [enabled, limit, total], + ); + + return [enabled ? Math.min(limit, total) : total, setSentinelRef]; +} + function buildTree( notes: NoteMeta[], assets: AssetMeta[], @@ -2587,10 +2794,33 @@ function FolderTreeContents({ () => getTreeRenderEntries(tree, showNotes, sortComparator, groupByKind), [tree, showNotes, sortComparator, groupByKind], ); + const progressiveEligible = shouldProgressivelyRenderEntries(entries); + const progressive = progressiveEligible && !sidebarFocused; + const [visibleEntryLimit, progressiveSentinelRef] = useProgressiveEntryLimit( + entries.length, + progressive, + ); + const selectedEntryIndex = useMemo( + () => + selectedPath + ? entries.findIndex((entry) => treeRenderEntryPath(entry) === selectedPath) + : -1, + [entries, selectedPath], + ); + const effectiveEntryLimit = getSidebarEntryLimitIncludingIndex( + visibleEntryLimit, + entries.length, + selectedEntryIndex, + ); + const visibleEntries = useMemo( + () => (progressive ? entries.slice(0, effectiveEntryLimit) : entries), + [effectiveEntryLimit, entries, progressive], + ); + useSidebarVisibleNotePrefetch(visibleEntries, showNotes); return ( <> - {entries.map((entry) => { + {visibleEntries.map((entry) => { if (entry.type === "folder") { return ( - onSelectItem(e, { kind: "note", path: n.path }, () => - onSelectNote(n.path), - ) - } - onContextMenu={(e) => onNoteContextMenu(e, n)} - dragPayload={dragPayloadForItem({ kind: "note", path: n.path })} + onSelectItem={onSelectItem} + onSelectNote={onSelectNote} + onContextMenuNote={onNoteContextMenu} + dragPayloadForItem={dragPayloadForItem} sidebarIdx={noteIdx} vimHighlight={vimCursor === noteIdx} /> ); })} + {progressive && effectiveEntryLimit < entries.length && ( +