feat: cache note content and parsed editor blocks

This commit is contained in:
lucaronin
2026-05-01 23:14:36 +02:00
parent fbfda2f15c
commit c8ac12f3dc
37 changed files with 1859 additions and 589 deletions

View File

@@ -153,6 +153,7 @@ import {
import type { VaultEntry } from '../types'
import { bindVaultConfigStore, resetVaultConfigStore } from '../utils/vaultConfigStore'
import { TooltipProvider } from '@/components/ui/tooltip'
import { clearParsedNoteBlockCache } from '../hooks/editorParsedBlockCache'
type EditorComponentProps = ComponentProps<typeof Editor>
@@ -235,6 +236,7 @@ describe('Editor', () => {
beforeEach(() => {
blockNoteCreation.options = []
blockNoteViewState.onChange = null
clearParsedNoteBlockCache()
})
it('shows empty state when no tabs are open', () => {

View File

@@ -17,6 +17,7 @@ import { useDragRegion } from '../hooks/useDragRegion'
import { formatShortcutDisplay } from '../hooks/appCommandCatalog'
import { EditorRightPanel } from './EditorRightPanel'
import { EditorContent } from './EditorContent'
import { EditorMemoryProbe } from './EditorMemoryProbe'
import { FilePreview } from './FilePreview'
import { schema } from './editorSchema'
import type { RawEditorFindRequest } from './RawEditorFindBar'
@@ -513,6 +514,7 @@ function EditorLayout({
locale={locale}
/>
</div>
<EditorMemoryProbe entries={entries} vaultPath={vaultPath} locale={locale} />
</div>
)
}
@@ -538,7 +540,6 @@ export const Editor = memo(function Editor(props: EditorProps) {
isConflicted, onKeepMine, onKeepTheirs,
flushPendingEditorContentRef, flushPendingRawContentRef, findInNoteRef, locale,
} = props
const {
editor, activeTab, rawLatestContentRef, rawModeContent,
rawMode, diffMode, diffContent, diffLoading,
@@ -560,7 +561,6 @@ export const Editor = memo(function Editor(props: EditorProps) {
handleToggleRawExclusive,
rawMode,
})
useRegisterEditorContentFlushes({
activeTab,
flushPendingEditorChange,
@@ -570,7 +570,6 @@ export const Editor = memo(function Editor(props: EditorProps) {
onContentChange,
flushPendingRawContentRef,
})
return (
<EditorLayout
tabs={tabs}

View File

@@ -0,0 +1,82 @@
import { useEffect } from 'react'
import type { AppLocale } from '../lib/i18n'
import type { VaultEntry } from '../types'
import { HiddenEditorMemoryProbe } from './HiddenEditorMemoryProbe'
import type { EditorMemoryProbeApi } from './editorMemoryProbeTypes'
import { useEditorMemoryProbeController } from './useEditorMemoryProbeController'
declare global {
interface Window {
__tolariaEditorMemoryProbe?: EditorMemoryProbeApi
}
}
function useEditorMemoryProbeBridge(api: EditorMemoryProbeApi): void {
useEffect(() => {
if (!import.meta.env.DEV) return
window.__tolariaEditorMemoryProbe = api
return () => {
if (window.__tolariaEditorMemoryProbe?.run === api.run) {
delete window.__tolariaEditorMemoryProbe
}
}
}, [api])
}
function useEditorMemoryProbeShortcut(runAndCopy: EditorMemoryProbeApi['runAndCopy']): void {
useEffect(() => {
if (!import.meta.env.DEV) return
const handleKeyDown = (event: KeyboardEvent) => {
if (!event.metaKey || !event.altKey || !event.shiftKey || event.code !== 'KeyM') return
event.preventDefault()
event.stopPropagation()
void runAndCopy()
}
window.addEventListener('keydown', handleKeyDown, { capture: true })
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true })
}, [runAndCopy])
}
export function EditorMemoryProbe({
entries,
locale,
vaultPath,
}: {
entries: VaultEntry[]
locale?: AppLocale
vaultPath?: string
}) {
const controller = useEditorMemoryProbeController(entries)
useEditorMemoryProbeBridge(controller)
useEditorMemoryProbeShortcut(controller.runAndCopy)
if (!import.meta.env.DEV || controller.targets.length === 0) return null
return (
<div
aria-hidden="true"
style={{
height: 1,
left: -100_000,
opacity: 0,
overflow: 'hidden',
pointerEvents: 'none',
position: 'fixed',
top: 0,
width: 1,
zIndex: -1,
}}
>
{controller.targets.map(target => (
<HiddenEditorMemoryProbe
key={target.entry.path}
entries={entries}
locale={locale}
onReady={controller.handleReady}
target={target}
vaultPath={vaultPath}
/>
))}
</div>
)
}

View File

@@ -0,0 +1,74 @@
import { useEffect } from 'react'
import { useCreateBlockNote } from '@blocknote/react'
import { uploadImageFile } from '../hooks/useImageDrop'
import { useEditorTabSwap } from '../hooks/useEditorTabSwap'
import { RUNTIME_STYLE_NONCE } from '../lib/runtimeStyleNonce'
import type { AppLocale } from '../lib/i18n'
import type { VaultEntry } from '../types'
import { createArrowLigaturesExtension } from './arrowLigaturesExtension'
import { createMathInputExtension } from './mathInputExtension'
import { schema } from './editorSchema'
import type { ProbeTarget } from './editorMemoryProbeTypes'
import { SingleEditorView } from './SingleEditorView'
function useProbeEditor(target: ProbeTarget, vaultPath?: string) {
const editor = useCreateBlockNote({
schema,
uploadFile: (file: File) => uploadImageFile(file, vaultPath),
_tiptapOptions: { injectNonce: RUNTIME_STYLE_NONCE },
extensions: [createArrowLigaturesExtension(), createMathInputExtension()],
})
useEditorTabSwap({
tabs: [{ entry: target.entry, content: target.content }],
activeTabPath: target.entry.path,
editor,
rawMode: false,
vaultPath,
})
return editor
}
function useProbeReadySignal(target: ProbeTarget, onReady: (path: string) => void): void {
useEffect(() => {
const handleSwap = (event: Event) => {
const detail = (event as CustomEvent<{ path?: string }>).detail
if (detail?.path === target.entry.path) onReady(target.entry.path)
}
window.addEventListener('laputa:editor-tab-swapped', handleSwap)
return () => window.removeEventListener('laputa:editor-tab-swapped', handleSwap)
}, [onReady, target.entry.path])
}
export function HiddenEditorMemoryProbe({
entries,
locale,
onReady,
target,
vaultPath,
}: {
entries: VaultEntry[]
locale?: AppLocale
onReady: (path: string) => void
target: ProbeTarget
vaultPath?: string
}) {
const editor = useProbeEditor(target, vaultPath)
useProbeReadySignal(target, onReady)
return (
<div
aria-hidden="true"
data-editor-memory-probe-path={target.entry.path}
style={{ height: 900, overflow: 'hidden', width: 900 }}
>
<SingleEditorView
editor={editor}
entries={entries}
onNavigateWikilink={() => {}}
editable={false}
vaultPath={vaultPath}
locale={locale}
/>
</div>
)
}

View File

@@ -328,7 +328,7 @@ type NoteItemProps = {
allEntries?: VaultEntry[]
displayPropsOverride?: string[] | null
onClickNote: (entry: VaultEntry, e: ReactMouseEvent) => void
onPrefetch?: (path: string) => void
onPrefetch?: (entry: VaultEntry) => void
onContextMenu?: (entry: VaultEntry, e: ReactMouseEvent) => void
}
@@ -409,7 +409,7 @@ function resolveNoteItemSurfaceProps({
style: resolveNoteItemSurfaceStyle({ isUnavailableBinary, isSelected, isMultiSelected, typeColor, typeLightColor }),
onClick: createNoteItemClickHandler(entry, isUnavailableBinary, onClickNote),
onContextMenu: onContextMenu ? (event) => onContextMenu(entry, event) : undefined,
onMouseEnter: entry.fileKind !== 'binary' && onPrefetch ? () => onPrefetch(entry.path) : undefined,
onMouseEnter: entry.fileKind !== 'binary' && onPrefetch ? () => onPrefetch(entry) : undefined,
testId: resolveNoteItemTestId({ isMultiSelected, previewKind, isUnavailableBinary }),
title: resolveNoteItemTitle({ previewKind, isUnavailableBinary }),
}

View File

@@ -115,6 +115,6 @@ describe('NoteList keyboard activation', () => {
fireEvent.mouseEnter(noteRow!)
expect(prefetchSpy).toHaveBeenCalledWith(mockEntries[1].path)
expect(prefetchSpy).toHaveBeenCalledWith(mockEntries[1])
})
})

View File

@@ -361,7 +361,10 @@ function EditorCanvas({
if (!showEditor) return null
return (
<EditorFindScope className="editor-scroll-area" style={cssVars as React.CSSProperties}>
<EditorFindScope
className="editor-scroll-area"
style={cssVars as React.CSSProperties}
>
<div className="editor-content-wrapper">
<SingleEditorView
editor={editor}

View File

@@ -0,0 +1,82 @@
import { invoke } from '@tauri-apps/api/core'
import { isTauri } from '../mock-tauri'
import type { VaultEntry } from '../types'
import type {
ProbeResult,
ProbeRunOptions,
ProbeTarget,
ProbeTargetSummary,
ProcessMemorySnapshot,
} from './editorMemoryProbeTypes'
export const DEFAULT_PROBE_LIMIT = 5
export const DEFAULT_PROBE_BATCH_SIZE = 1
export const DEFAULT_PROBE_SETTLE_MS = 700
export const PROBE_READY_TIMEOUT_MS = 30_000
function wait(ms: number): Promise<void> {
return new Promise(resolve => window.setTimeout(resolve, ms))
}
function contentBytes(content: string): number {
return new TextEncoder().encode(content).byteLength
}
export function summarizeTarget({ entry, content }: ProbeTarget): ProbeTargetSummary {
return {
path: entry.path,
fileSize: entry.fileSize,
contentBytes: contentBytes(content),
lineCount: content.split('\n').length,
}
}
export function memoryDelta(
snapshot: ProcessMemorySnapshot | null,
baseline: ProcessMemorySnapshot | null,
): number | null {
if (!snapshot || !baseline) return null
return snapshot.totalRssBytes - baseline.totalRssBytes
}
export function selectProbeEntries(entries: VaultEntry[], options: ProbeRunOptions): VaultEntry[] {
const markdownEntries = entries.filter(entry => (entry.fileKind ?? 'markdown') === 'markdown')
if (options.paths?.length) {
const wantedPaths = new Set(options.paths)
return markdownEntries.filter(entry => wantedPaths.has(entry.path))
}
return [...markdownEntries]
.sort((left, right) => right.fileSize - left.fileSize)
.slice(0, options.limit ?? DEFAULT_PROBE_LIMIT)
}
export function resolveMountCounts(targetCount: number, batchSize: number): number[] {
const counts: number[] = []
for (let count = batchSize; count < targetCount; count += batchSize) {
counts.push(count)
}
if (targetCount > 0) counts.push(targetCount)
return counts
}
export async function readMemorySnapshot(): Promise<ProcessMemorySnapshot | null> {
if (!isTauri()) return null
return invoke<ProcessMemorySnapshot>('get_process_memory_snapshot')
}
export async function loadProbeTarget(entry: VaultEntry): Promise<ProbeTarget> {
const content = await invoke<string>('get_note_content', { path: entry.path })
return { entry, content }
}
export async function copyProbeResult(result: ProbeResult): Promise<void> {
if (!isTauri()) return
await invoke('copy_text_to_clipboard', {
text: JSON.stringify(result, null, 2),
})
}
export function settleAfterMount(settleMs: number): Promise<void> {
return wait(settleMs).then(() => new Promise<void>(resolve => requestAnimationFrame(() => resolve())))
}

View File

@@ -0,0 +1,61 @@
import type { VaultEntry } from '../types'
export interface ProbeTarget {
entry: VaultEntry
content: string
}
export interface ProcessMemoryEntry {
pid: number
parentPid: number
rssBytes: number
role: string
command: string
}
export interface ProcessMemorySnapshot {
currentPid: number
totalRssBytes: number
entries: ProcessMemoryEntry[]
}
export interface ProbeRunOptions {
paths?: string[]
limit?: number
batchSize?: number
settleMs?: number
}
export interface ProbeStep {
mountedCount: number
mountedPaths: string[]
snapshot: ProcessMemorySnapshot | null
deltaBytes: number | null
}
export interface ProbeTargetSummary {
path: string
fileSize: number
contentBytes: number
lineCount: number
}
export interface ProbeResult {
targets: ProbeTargetSummary[]
baseline: ProcessMemorySnapshot | null
afterContentLoad: ProcessMemorySnapshot | null
contentLoadDeltaBytes: number | null
steps: ProbeStep[]
}
export interface ProbeWaiter {
paths: Set<string>
resolve: () => void
timer: number
}
export interface EditorMemoryProbeApi {
run: (options?: ProbeRunOptions) => Promise<ProbeResult>
runAndCopy: (options?: ProbeRunOptions) => Promise<ProbeResult>
clear: () => void
}

View File

@@ -63,7 +63,7 @@ vi.mock('../../hooks/useNoteListKeyboard', () => ({
}))
vi.mock('../../hooks/useTabManagement', () => ({
prefetchNoteContent: (path: string) => prefetchNoteContentMock(path),
prefetchNoteContent: (entry: VaultEntry) => prefetchNoteContentMock(entry),
}))
vi.mock('./noteListUtils', async () => {
@@ -475,8 +475,8 @@ describe('noteListHooks extra', () => {
expect(onOpenDeletedNote).toHaveBeenCalledWith(deletedEntry)
expect(onReplaceActiveTab).toHaveBeenCalledWith(liveEntry)
expect(onAutoTriggerDiff).toHaveBeenCalledOnce()
expect(prefetchNoteContentMock).toHaveBeenCalledWith(liveEntry.path)
expect(prefetchNoteContentMock).not.toHaveBeenCalledWith(imageEntry.path)
expect(prefetchNoteContentMock).toHaveBeenCalledWith(liveEntry)
expect(prefetchNoteContentMock).not.toHaveBeenCalledWith(imageEntry)
vi.useRealTimers()
})

View File

@@ -1046,7 +1046,7 @@ function useKeyboardInteractionState({
}, [onOpenDeletedNote, onReplaceActiveTab])
const handleKeyboardPrefetch = useCallback((entry: VaultEntry) => {
if (canPrefetchEntryContent(entry)) prefetchNoteContent(entry.path)
if (canPrefetchEntryContent(entry)) prefetchNoteContent(entry)
}, [])
const handleNeighborhoodOpen = useCallback(async (entry: VaultEntry) => {

View File

@@ -32,8 +32,10 @@ import { useChangesContextMenu } from './NoteListChangesMenu'
import { addNoteListSearchToggleListener, dispatchNoteListSearchAvailability } from '../../utils/noteListSearchEvents'
type EntitySelection = Extract<SidebarSelection, { kind: 'entity' }>
const LIKELY_NEXT_PRELOAD_LIMIT = 8
const ADJACENT_PRELOAD_RADIUS = 2
const LIKELY_NEXT_PRELOAD_LIMIT = 6
const ADJACENT_PRELOAD_RADIUS = 3
const LIKELY_NEXT_PRELOAD_START_DELAY_MS = 350
const LIKELY_NEXT_PRELOAD_STEP_DELAY_MS = 180
function useViewFlags(selection: SidebarSelection) {
const isSectionGroup = selection.kind === 'sectionGroup'
@@ -58,7 +60,13 @@ function likelyNextPreloadEntries(entries: VaultEntry[], selectedNotePath: strin
: Math.min(entries.length, LIKELY_NEXT_PRELOAD_LIMIT)
return entries
.slice(start, end)
.filter((entry) => !isDeletedNoteEntry(entry) && entry.fileKind !== 'binary')
.map((entry, offset) => ({ entry, index: start + offset }))
.sort((left, right) => {
if (selectedIndex < 0) return 0
return Math.abs(left.index - selectedIndex) - Math.abs(right.index - selectedIndex)
})
.map(({ entry }) => entry)
.filter((entry) => entry.path !== selectedNotePath && !isDeletedNoteEntry(entry) && entry.fileKind !== 'binary')
.slice(0, LIKELY_NEXT_PRELOAD_LIMIT)
}
@@ -67,11 +75,23 @@ function useLikelyNextPreload(entries: VaultEntry[], selectedNotePath: string |
const candidates = likelyNextPreloadEntries(entries, selectedNotePath)
if (candidates.length === 0) return
const timer = window.setTimeout(() => {
for (const entry of candidates) prefetchNoteContent(entry.path)
}, 100)
let stepTimer: number | null = null
let candidateIndex = 0
const startTimer = window.setTimeout(() => {
const preloadNext = () => {
const entry = candidates[candidateIndex]
if (!entry) return
candidateIndex += 1
prefetchNoteContent(entry)
stepTimer = window.setTimeout(preloadNext, LIKELY_NEXT_PRELOAD_STEP_DELAY_MS)
}
preloadNext()
}, LIKELY_NEXT_PRELOAD_START_DELAY_MS)
return () => window.clearTimeout(timer)
return () => {
window.clearTimeout(startTimer)
if (stepTimer !== null) window.clearTimeout(stepTimer)
}
}, [entries, selectedNotePath])
}

View File

@@ -0,0 +1,131 @@
import { useCallback, useRef, useState } from 'react'
import type { VaultEntry } from '../types'
import type {
ProbeResult,
ProbeRunOptions,
ProbeStep,
ProbeTarget,
ProbeWaiter,
} from './editorMemoryProbeTypes'
import {
copyProbeResult,
DEFAULT_PROBE_BATCH_SIZE,
DEFAULT_PROBE_SETTLE_MS,
loadProbeTarget,
memoryDelta,
readMemorySnapshot,
resolveMountCounts,
selectProbeEntries,
settleAfterMount,
summarizeTarget,
PROBE_READY_TIMEOUT_MS,
} from './editorMemoryProbeRuntime'
function createProbeStep(
mountedTargets: ProbeTarget[],
snapshot: Awaited<ReturnType<typeof readMemorySnapshot>>,
baseline: Awaited<ReturnType<typeof readMemorySnapshot>>,
): ProbeStep {
return {
mountedCount: mountedTargets.length,
mountedPaths: mountedTargets.map(target => target.entry.path),
snapshot,
deltaBytes: memoryDelta(snapshot, baseline),
}
}
function useProbeReadiness() {
const readyPathsRef = useRef(new Set<string>())
const waiterRef = useRef<ProbeWaiter | null>(null)
const resolveWaiterIfReady = useCallback(() => {
const waiter = waiterRef.current
if (!waiter) return
for (const path of waiter.paths) {
if (!readyPathsRef.current.has(path)) return
}
window.clearTimeout(waiter.timer)
waiterRef.current = null
waiter.resolve()
}, [])
const handleReady = useCallback((path: string) => {
readyPathsRef.current.add(path)
resolveWaiterIfReady()
}, [resolveWaiterIfReady])
const waitForReadyPaths = useCallback((paths: string[]) => {
return new Promise<void>((resolve) => {
const unresolvedPaths = paths.filter(path => !readyPathsRef.current.has(path))
if (unresolvedPaths.length === 0) {
resolve()
return
}
const timer = window.setTimeout(() => {
waiterRef.current = null
console.warn('[memory-probe] Timed out waiting for hidden editors:', unresolvedPaths)
resolve()
}, PROBE_READY_TIMEOUT_MS)
waiterRef.current = { paths: new Set(paths), resolve, timer }
})
}, [])
const clearReadiness = useCallback(() => {
if (waiterRef.current) {
window.clearTimeout(waiterRef.current.timer)
waiterRef.current = null
}
readyPathsRef.current.clear()
}, [])
return { clearReadiness, handleReady, waitForReadyPaths }
}
export function useEditorMemoryProbeController(entries: VaultEntry[]) {
const [targets, setTargets] = useState<ProbeTarget[]>([])
const { clearReadiness, handleReady, waitForReadyPaths } = useProbeReadiness()
const clear = useCallback(() => {
clearReadiness()
setTargets([])
}, [clearReadiness])
const run = useCallback(async (options: ProbeRunOptions = {}): Promise<ProbeResult> => {
clear()
await settleAfterMount(options.settleMs ?? DEFAULT_PROBE_SETTLE_MS)
const baseline = await readMemorySnapshot()
const selectedEntries = selectProbeEntries(entries, options)
const loadedTargets = await Promise.all(selectedEntries.map(loadProbeTarget))
const afterContentLoad = await readMemorySnapshot()
const batchSize = Math.max(1, options.batchSize ?? DEFAULT_PROBE_BATCH_SIZE)
const settleMs = options.settleMs ?? DEFAULT_PROBE_SETTLE_MS
const steps: ProbeStep[] = []
for (const count of resolveMountCounts(loadedTargets.length, batchSize)) {
const mountedTargets = loadedTargets.slice(0, count)
setTargets(mountedTargets)
await waitForReadyPaths(mountedTargets.map(target => target.entry.path))
await settleAfterMount(settleMs)
steps.push(createProbeStep(mountedTargets, await readMemorySnapshot(), baseline))
}
return {
targets: loadedTargets.map(summarizeTarget),
baseline,
afterContentLoad,
contentLoadDeltaBytes: memoryDelta(afterContentLoad, baseline),
steps,
}
}, [clear, entries, waitForReadyPaths])
const runAndCopy = useCallback(async (options: ProbeRunOptions = {}) => {
const result = await run(options)
await copyProbeResult(result)
console.info('[memory-probe] Result copied to clipboard', result)
return result
}, [run])
return { clear, handleReady, run, runAndCopy, targets }
}