fix: stop note navigation from scaling with vault size
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
106
src/utils/noteOpenPerformance.ts
Normal file
106
src/utils/noteOpenPerformance.ts
Normal 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)}`)
|
||||
}
|
||||
Reference in New Issue
Block a user