fix: stop note navigation from scaling with vault size

This commit is contained in:
lucaronin
2026-04-19 19:30:58 +02:00
parent 261a5befc7
commit 977f5593fa
5 changed files with 186 additions and 6 deletions

View File

@@ -21,6 +21,21 @@ interface VisibilityState {
showEditor: boolean
}
const entryLookupCache = new WeakMap<VaultEntry[], Map<string, VaultEntry>>()
function getEntryLookup(entries: VaultEntry[]): Map<string, VaultEntry> {
const cached = entryLookupCache.get(entries)
if (cached) return cached
const lookup = new Map<string, VaultEntry>()
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 {

View File

@@ -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')
})
}

View File

@@ -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<string, VaultEntry>
indexByPath: Map<string, number>
}
const itemIndexCache = new WeakMap<VaultEntry[], ItemIndex>()
function buildItemIndex(items: VaultEntry[]): ItemIndex {
const entryByPath = new Map<string, VaultEntry>()
const indexByPath = new Map<string, number>()
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<KeyboardEvent, 'metaKey' | 'ctrlKey'>): 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

View File

@@ -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,

View File

@@ -0,0 +1,106 @@
type NoteOpenStage =
| 'beforeNavigateStart'
| 'beforeNavigateEnd'
| 'cacheReady'
| 'contentLoadStart'
| 'contentLoadEnd'
| 'editorSwapped'
interface NoteOpenTrace {
startedAt: number
source: string
marks: Partial<Record<NoteOpenStage, number>>
}
const inFlightNoteOpens = new Map<string, NoteOpenTrace>()
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)}`)
}