diff --git a/src/components/editor-content/editorContentState.ts b/src/components/editor-content/editorContentState.ts index 23d79e40..75eb4400 100644 --- a/src/components/editor-content/editorContentState.ts +++ b/src/components/editor-content/editorContentState.ts @@ -21,6 +21,21 @@ interface VisibilityState { showEditor: boolean } +const entryLookupCache = new WeakMap>() + +function getEntryLookup(entries: VaultEntry[]): Map { + const cached = entryLookupCache.get(entries) + if (cached) return cached + + const lookup = new Map() + for (const entry of entries) { + lookup.set(entry.path, entry) + } + + entryLookupCache.set(entries, lookup) + return lookup +} + export interface EditorContentState { freshEntry: VaultEntry | undefined isArchived: boolean @@ -35,7 +50,7 @@ export interface EditorContentState { function findFreshEntry(activeTab: EditorContentTab | null, entries: VaultEntry[]): VaultEntry | undefined { if (!activeTab) return undefined - return entries.find((entry) => entry.path === activeTab.entry.path) + return getEntryLookup(entries).get(activeTab.entry.path) } function contentHasTopLevelH1(activeTab: EditorContentTab | null): boolean { diff --git a/src/hooks/useEditorTabSwap.ts b/src/hooks/useEditorTabSwap.ts index 84445009..52153806 100644 --- a/src/hooks/useEditorTabSwap.ts +++ b/src/hooks/useEditorTabSwap.ts @@ -3,6 +3,7 @@ import type { useCreateBlockNote } from '@blocknote/react' import type { VaultEntry } from '../types' import { splitFrontmatter, preProcessWikilinks, injectWikilinks, restoreWikilinksInBlocks } from '../utils/wikilinks' import { compactMarkdown } from '../utils/compact-markdown' +import { failNoteOpenTrace, finishNoteOpenTrace } from '../utils/noteOpenPerformance' interface Tab { entry: VaultEntry @@ -36,6 +37,7 @@ function signalEditorTabSwapped(path: string): void { window.dispatchEvent(new CustomEvent('laputa:editor-tab-swapped', { detail: { path }, })) + finishNoteOpenTrace(path) } /** Strip the YAML frontmatter from raw file content, returning the body @@ -643,6 +645,7 @@ function scheduleEmptyHeadingSwap(options: { .catch((err: unknown) => { suppressChangeRef.current = false console.error('Failed to render empty heading state:', err) + failNoteOpenTrace(targetPath, 'empty-heading-swap-failed') }) return true @@ -674,6 +677,7 @@ function scheduleParsedBlockSwap(options: { .catch((err: unknown) => { suppressChangeRef.current = false console.error('Failed to parse/swap editor content:', err) + failNoteOpenTrace(targetPath, 'parsed-swap-failed') }) } diff --git a/src/hooks/useNoteListKeyboard.ts b/src/hooks/useNoteListKeyboard.ts index b4dfedd5..9a2fa902 100644 --- a/src/hooks/useNoteListKeyboard.ts +++ b/src/hooks/useNoteListKeyboard.ts @@ -1,6 +1,7 @@ import { useState, useCallback, useEffect, useRef } from 'react' import type { VirtuosoHandle } from 'react-virtuoso' import type { VaultEntry } from '../types' +import { logKeyboardNavigationTrace } from '../utils/noteOpenPerformance' interface NoteListKeyboardOptions { items: VaultEntry[] @@ -11,11 +12,39 @@ interface NoteListKeyboardOptions { enabled: boolean } +interface ItemIndex { + entryByPath: Map + indexByPath: Map +} + +const itemIndexCache = new WeakMap() + +function buildItemIndex(items: VaultEntry[]): ItemIndex { + const entryByPath = new Map() + const indexByPath = new Map() + + for (const [index, entry] of items.entries()) { + entryByPath.set(entry.path, entry) + indexByPath.set(entry.path, index) + } + + return { entryByPath, indexByPath } +} + +function getItemIndex(items: VaultEntry[]): ItemIndex { + const cached = itemIndexCache.get(items) + if (cached) return cached + + const nextIndex = buildItemIndex(items) + itemIndexCache.set(items, nextIndex) + return nextIndex +} + function resolveHighlightedPath(items: VaultEntry[], selectedNotePath: string | null): string | null { if (items.length === 0) return null if (!selectedNotePath) return items[0].path - return items.some((entry) => entry.path === selectedNotePath) + return getItemIndex(items).entryByPath.has(selectedNotePath) ? selectedNotePath : items[0].path } @@ -63,7 +92,8 @@ function resolveCurrentIndex( selectedNotePath: string | null, ): number { const activePath = highlightedPath ?? selectedNotePath - return activePath ? items.findIndex((entry) => entry.path === activePath) : -1 + if (!activePath) return -1 + return getItemIndex(items).indexByPath.get(activePath) ?? -1 } function moveHighlightIndex( @@ -82,7 +112,7 @@ function moveHighlightIndex( function resolveHighlightedEntry(items: VaultEntry[], highlightedPath: string | null): VaultEntry | undefined { if (!highlightedPath) return undefined - return items.find((entry) => entry.path === highlightedPath) + return getItemIndex(items).entryByPath.get(highlightedPath) } function usesCommandModifier(event: Pick): boolean { @@ -210,6 +240,7 @@ function useMoveHighlight({ scheduleOpen: (entry: VaultEntry) => void }) { return useCallback((direction: 1 | -1) => { + const startedAt = performance.now() const currentIndex = resolveCurrentIndex(items, highlightedPathRef.current, selectedNotePath) const nextIndex = moveHighlightIndex(currentIndex, direction, items.length) const currentPath = highlightedPathRef.current ?? selectedNotePath @@ -220,6 +251,7 @@ function useMoveHighlight({ virtuosoRef.current?.scrollIntoView({ index: nextIndex, behavior: 'auto' }) scheduleOpen(nextItem) onPrefetch?.(nextItem) + logKeyboardNavigationTrace(direction === 1 ? 'down' : 'up', items.length, performance.now() - startedAt) }, [highlightedPathRef, items, onPrefetch, scheduleOpen, selectedNotePath, syncHighlightedPath, virtuosoRef]) } @@ -440,7 +472,7 @@ export function useNoteListKeyboard({ cancelOpen() }, [cancelOpen, selectedNotePath]) - const highlightedPath = items.some((entry) => entry.path === highlightedPathState) + const highlightedPath = getItemIndex(items).entryByPath.has(highlightedPathState ?? '') ? highlightedPathState : null diff --git a/src/hooks/useTabManagement.ts b/src/hooks/useTabManagement.ts index fa6c05db..0e9f4b6f 100644 --- a/src/hooks/useTabManagement.ts +++ b/src/hooks/useTabManagement.ts @@ -2,6 +2,12 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { invoke } from '@tauri-apps/api/core' import { isTauri, mockInvoke } from '../mock-tauri' import type { VaultEntry } from '../types' +import { + beginNoteOpenTrace, + failNoteOpenTrace, + finishNoteOpenTrace, + markNoteOpenTrace, +} from '../utils/noteOpenPerformance' interface Tab { entry: VaultEntry @@ -144,6 +150,7 @@ function startEntryNavigation(options: { const cachedContent = getCachedNoteContent(entry.path) syncActiveTabPath(activeTabPathRef, setActiveTabPath, entry.path) if (cachedContent !== null) { + markNoteOpenTrace(entry.path, 'cacheReady') setSingleTab(tabsRef, setTabs, { entry, content: cachedContent }) } @@ -191,6 +198,7 @@ function handleEntryLoadFailure(options: { console.warn('Failed to load note content:', error) if (navSeqRef.current !== seq) return setSingleTab(tabsRef, setTabs, { entry, content: '' }) + failNoteOpenTrace(entry.path, 'load-failed') } async function navigateToEntry(options: { @@ -210,9 +218,13 @@ async function navigateToEntry(options: { setActiveTabPath, } = options - if (entry.fileKind === 'binary') return + if (entry.fileKind === 'binary') { + failNoteOpenTrace(entry.path, 'binary-entry') + return + } if (isAlreadyViewingPath(tabsRef, activeTabPathRef, entry.path)) { syncActiveTabPath(activeTabPathRef, setActiveTabPath, entry.path) + finishNoteOpenTrace(entry.path) return } @@ -226,7 +238,9 @@ async function navigateToEntry(options: { }) try { + markNoteOpenTrace(entry.path, 'contentLoadStart') const content = await loadNoteContent(entry.path) + markNoteOpenTrace(entry.path, 'contentLoadEnd') if (!shouldApplyLoadedEntry({ seq, navSeqRef, @@ -270,9 +284,12 @@ export function useTabManagement(options: TabManagementOptions = {}) { const currentPath = activeTabPathRef.current if (beforeNavigate && currentPath && currentPath !== targetPath) { try { + markNoteOpenTrace(targetPath, 'beforeNavigateStart') await beforeNavigate(currentPath, targetPath) + markNoteOpenTrace(targetPath, 'beforeNavigateEnd') } catch (err) { console.warn('Failed to persist note before navigation:', err) + failNoteOpenTrace(targetPath, 'before-navigate-failed') return } if (beforeNavigateSeqRef.current !== seq) return @@ -282,6 +299,9 @@ export function useTabManagement(options: TabManagementOptions = {}) { /** Open a note — replaces the current note (single-note model). */ const handleSelectNote = useCallback(async (entry: VaultEntry) => { + if (entry.path !== activeTabPathRef.current) { + beginNoteOpenTrace(entry.path, 'select-note') + } await executeNavigationWithBoundary(entry.path, () => navigateToEntry({ entry, navSeqRef, @@ -305,6 +325,9 @@ export function useTabManagement(options: TabManagementOptions = {}) { }, [executeNavigationWithBoundary]) const handleReplaceActiveTab = useCallback(async (entry: VaultEntry) => { + if (entry.path !== activeTabPathRef.current) { + beginNoteOpenTrace(entry.path, 'replace-active-tab') + } await executeNavigationWithBoundary(entry.path, () => navigateToEntry({ entry, navSeqRef, diff --git a/src/utils/noteOpenPerformance.ts b/src/utils/noteOpenPerformance.ts new file mode 100644 index 00000000..1f3b1584 --- /dev/null +++ b/src/utils/noteOpenPerformance.ts @@ -0,0 +1,106 @@ +type NoteOpenStage = + | 'beforeNavigateStart' + | 'beforeNavigateEnd' + | 'cacheReady' + | 'contentLoadStart' + | 'contentLoadEnd' + | 'editorSwapped' + +interface NoteOpenTrace { + startedAt: number + source: string + marks: Partial> +} + +const inFlightNoteOpens = new Map() + +function isVitestRuntime(): boolean { + return typeof process !== 'undefined' && process.env.VITEST === 'true' +} + +function canMeasurePerformance(): boolean { + return import.meta.env.DEV && typeof performance !== 'undefined' && !isVitestRuntime() +} + +function formatDuration(durationMs: number | null): string { + return durationMs === null ? 'n/a' : `${durationMs.toFixed(1)}ms` +} + +function measureDuration( + trace: NoteOpenTrace, + start: keyof NoteOpenTrace['marks'] | 'startedAt', + end: keyof NoteOpenTrace['marks'], +): number | null { + const startTime = start === 'startedAt' ? trace.startedAt : trace.marks[start] + const endTime = trace.marks[end] + if (startTime === undefined || endTime === undefined) return null + return endTime - startTime +} + +function logPerf(message: string): void { + if (!canMeasurePerformance()) return + console.debug(`[perf] ${message}`) +} + +export function beginNoteOpenTrace(path: string, source: string): void { + if (!canMeasurePerformance()) return + + const startedAt = performance.now() + for (const [otherPath, trace] of inFlightNoteOpens) { + if (otherPath === path) continue + logPerf(`noteOpen cancel path=${otherPath} source=${trace.source} total=${formatDuration(startedAt - trace.startedAt)} reason=superseded`) + inFlightNoteOpens.delete(otherPath) + } + + inFlightNoteOpens.set(path, { startedAt, source, marks: {} }) +} + +export function markNoteOpenTrace(path: string, stage: NoteOpenStage): void { + if (!canMeasurePerformance()) return + const trace = inFlightNoteOpens.get(path) + if (!trace) return + trace.marks[stage] = performance.now() +} + +export function finishNoteOpenTrace(path: string): void { + if (!canMeasurePerformance()) return + const trace = inFlightNoteOpens.get(path) + if (!trace) return + + trace.marks.editorSwapped = performance.now() + const total = trace.marks.editorSwapped - trace.startedAt + const beforeNavigate = measureDuration(trace, 'beforeNavigateStart', 'beforeNavigateEnd') + const contentLoad = measureDuration(trace, 'contentLoadStart', 'contentLoadEnd') + const editorSwap = measureDuration(trace, 'contentLoadEnd', 'editorSwapped') + ?? measureDuration(trace, 'beforeNavigateEnd', 'editorSwapped') + ?? measureDuration(trace, 'startedAt', 'editorSwapped') + + logPerf( + `noteOpen path=${path} source=${trace.source} total=${formatDuration(total)} ` + + `beforeNavigate=${formatDuration(beforeNavigate)} ` + + `contentLoad=${formatDuration(contentLoad)} ` + + `editorSwap=${formatDuration(editorSwap)} ` + + `cache=${trace.marks.cacheReady !== undefined ? 'hit' : 'miss'}`, + ) + inFlightNoteOpens.delete(path) +} + +export function failNoteOpenTrace(path: string, reason: string): void { + if (!canMeasurePerformance()) return + const trace = inFlightNoteOpens.get(path) + if (!trace) return + + const total = performance.now() - trace.startedAt + logPerf(`noteOpen cancel path=${path} source=${trace.source} total=${formatDuration(total)} reason=${reason}`) + inFlightNoteOpens.delete(path) +} + +export function logKeyboardNavigationTrace( + direction: 'up' | 'down', + itemCount: number, + durationMs: number, +): void { + if (!canMeasurePerformance()) return + if (itemCount < 500 && durationMs < 4) return + logPerf(`noteListKeyboard direction=${direction} items=${itemCount} move=${formatDuration(durationMs)}`) +}