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

@@ -166,9 +166,9 @@ Asset previewability is inferred in the renderer from the filename extension (`s
### Note Content Freshness
The renderer may cache recently opened or preloaded markdown content, but cached content is only a performance hint. Before showing cached markdown or editor-ready blocks, `useTabManagement` validates the cached string with the `validate_note_content` Tauri command. That command re-enters the same vault path boundary checks as `get_note_content` and compares the cached text against the current on-disk file bytes. A mismatch, missing file, or unreadable file falls back to the normal fresh-read path and existing missing/unreadable recovery.
The renderer may cache recently opened or preloaded markdown content, but cached content is only a performance hint. `useTabManagement` can reuse cached text immediately when it carries the same `modifiedAt` and `fileSize` identity as the current `VaultEntry`; otherwise it validates the cached string with the `validate_note_content` Tauri command. That command re-enters the same vault path boundary checks as `get_note_content` and compares the cached text against the current on-disk file bytes. A mismatch, missing file, or unreadable file falls back to the normal fresh-read path and existing missing/unreadable recovery.
Prepared BlockNote blocks in `useEditorTabSwap` are keyed by path plus source content. They can be built ahead of time from prefetched markdown, but they are reused only when the validated raw content for that path is identical to the source content that produced the blocks.
`useEditorTabSwap` may reuse BlockNote blocks that were already opened successfully or warmed from prefetched raw content, keyed by vault, path, and exact source content. Background warming is limited to likely next large Markdown notes and defers while the editor is unmounted, raw mode is active, or recent typing/navigation is still inside the foreground idle window. Every async editor swap carries a generation and source-content token so stale conversion results cannot overwrite newer file content or dirty editor state.
### Entity Types (isA / type)

View File

@@ -102,9 +102,9 @@ Large-vault reproduction and keyboard QA steps live in [LARGE-VAULT-LOADING-QA.m
#### Note Opening Fast Path
Note opening uses a bounded in-memory fast path split across raw content and editor-ready blocks. `useTabManagement` owns the raw markdown prefetch cache and `useEditorTabSwap` owns the prepared BlockNote block cache. Cached or preloaded markdown is never rendered directly: before reusing it, the renderer calls the `validate_note_content` Tauri command, which compares the cached text with the current file bytes inside the validated vault boundary. If validation fails, Tolaria discards the cached entry and reads fresh disk content before swapping the editor.
Note opening uses bounded in-memory fast paths for raw content and parsed editor blocks. `useTabManagement` owns the markdown/text prefetch cache and treats every cached value as a performance hint only: identity-matched entries (`modifiedAt` + `fileSize`) can be reused immediately, while identity-missing or identity-mismatched cached text is checked with `validate_note_content`, which compares the cached text with the current file bytes inside the validated vault boundary. If validation fails, Tolaria discards the cached entry and reads fresh disk content before swapping the editor.
The note list opportunistically preloads visible and adjacent markdown/text entries after a short idle delay. Once raw content resolves, the editor prepares BlockNote blocks in the background when it is mounted and not in raw mode. This targets the expensive markdown-to-editor conversion stage while keeping filesystem content authoritative and keeping preload memory bounded by the same cache limits as ordinary note switches.
The note list opportunistically preloads visible and adjacent markdown/text entries after a short delay. When a large warmed Markdown note resolves, `useEditorTabSwap` may parse it into a bounded parsed-block cache only after foreground editor work has been idle and the rich editor is mounted. Parsed blocks are keyed by vault, path, and exact source content; every async swap carries a generation/source-content token so stale conversion results cannot overwrite newer file content or dirty editor state. The editor never renders a preview surface that later morphs into BlockNote. See [ADR-0105](./adr/0105-editor-correctness-and-responsiveness-contract.md).
## Tech Stack

View File

@@ -0,0 +1,39 @@
---
type: ADR
id: "0105"
title: "Editor correctness and responsiveness contract"
status: active
date: 2026-05-01
---
## Context
Tolaria notes are durable Markdown files on disk, but the rich editor renders them through BlockNote blocks. Large notes exposed a tempting optimization: show a fast Markdown preview first, then hydrate BlockNote later. In practice, that creates two renderers for the same document, visible flicker, delayed click-time lag, and more places for stale async work to race with the currently selected note.
The editor must optimize for the product priorities in this order:
1. no crashes
2. no stale content, race-condition overwrites, or lost edits
3. responsive typing and cursor movement
4. fast note-list-to-editor loading
## Decision
**Tolaria keeps a single direct editor surface for Markdown notes and treats editor content swaps as generation-checked, source-content-checked operations.** Fast loading may use raw file-content prefetching and a bounded parsed-block cache, but it must not show a separate preview that later swaps into the editor. Parsed BlockNote blocks are reusable only when their source Markdown exactly matches the content being opened, and background parsing must run only after recent typing/navigation has gone idle.
## Options considered
- **Single direct editor surface with guarded swaps plus bounded caches** (chosen): preserves one visual representation of the document, rejects stale async parse results, validates or identity-checks cached disk content before opening, and keeps typing work debounced. Cons: very large notes can still wait on BlockNote conversion when they were not warmed.
- **Fast Markdown preview followed by hidden BlockNote hydration**: improves first paint but creates flicker, delayed edit-time stalls, and duplicated rendering semantics.
- **Unbounded or eager background BlockNote parsing for likely next notes**: can make some opens faster, but competes with typing/navigation and introduces stale parse-result hazards unless heavily scheduled, bounded, and invalidated.
- **Always raw mode for large notes**: strongest responsiveness for huge files, but changes the editing experience abruptly and should be an explicit fallback rather than the default.
## Consequences
- Async editor work must prove it still matches both the latest swap generation and the latest tab source content before touching BlockNote.
- Cached raw note content must be validated against disk before it is shown unless it carries the same `modifiedAt` and `fileSize` identity as the current `VaultEntry`, or the content was just authored by Tolaria in the current process.
- Cached parsed BlockNote blocks must be keyed by vault, path, and exact source content, cloned on read/write, and bounded by entry count plus source byte budget.
- Background parsed-block warming is allowed only for likely next large Markdown notes after a foreground idle window; active typing, raw mode, and editor mount state must defer it.
- Dirty local editor content remains authoritative. External filesystem refreshes may replace clean notes, but must not overwrite unsaved local edits.
- Per-keystroke editor work must stay minimal. Serialization, metadata derivation, autosave, and cache updates should be debounced, coalesced, or scheduled away from active typing.
- Future large-note optimizations should target true progressive/chunked conversion or explicit raw/read-only fallback states, not a visually different preview that morphs into BlockNote.

View File

@@ -157,3 +157,4 @@ proposed → active → superseded
| [0102](0102-low-end-safe-autosave-idle-window.md) | Low-end-safe autosave idle window | active |
| [0103](0103-adapter-specific-ai-permission-semantics.md) | Adapter-specific AI permission semantics | active |
| [0104](0104-tauri-frontend-readiness-watchdog.md) | Tauri frontend readiness watchdog | active |
| [0105](0105-editor-correctness-and-responsiveness-contract.md) | Editor correctness and responsiveness contract | active |

View File

@@ -0,0 +1,167 @@
use serde::Serialize;
use std::process::Command;
const WEBKIT_AUX_PID_WINDOW: u32 = 512;
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProcessMemoryEntry {
pub pid: u32,
pub parent_pid: u32,
pub rss_bytes: u64,
pub role: String,
pub command: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProcessMemorySnapshot {
pub current_pid: u32,
pub total_rss_bytes: u64,
pub entries: Vec<ProcessMemoryEntry>,
}
struct ProcessRow {
pid: u32,
parent_pid: u32,
rss_kib: u64,
command: String,
}
#[tauri::command]
pub fn get_process_memory_snapshot() -> Result<ProcessMemorySnapshot, String> {
let current_pid = std::process::id();
let entries = collect_related_process_memory(current_pid)?;
let total_rss_bytes = entries.iter().map(|entry| entry.rss_bytes).sum();
Ok(ProcessMemorySnapshot {
current_pid,
total_rss_bytes,
entries,
})
}
fn collect_related_process_memory(current_pid: u32) -> Result<Vec<ProcessMemoryEntry>, String> {
let rows = read_process_rows()?;
Ok(rows
.into_iter()
.filter_map(|row| related_process_entry(row, current_pid))
.collect())
}
fn related_process_entry(row: ProcessRow, current_pid: u32) -> Option<ProcessMemoryEntry> {
let role = classify_related_process(&row, current_pid)?;
Some(ProcessMemoryEntry {
pid: row.pid,
parent_pid: row.parent_pid,
rss_bytes: row.rss_kib.saturating_mul(1024),
role,
command: row.command,
})
}
fn classify_related_process(row: &ProcessRow, current_pid: u32) -> Option<String> {
if row.pid == current_pid {
return Some("app".to_string());
}
if !is_nearby_webkit_auxiliary(row, current_pid) {
return None;
}
if row.command.contains("WebKit.WebContent") {
return Some("webkit-webcontent".to_string());
}
if row.command.contains("WebKit.GPU") {
return Some("webkit-gpu".to_string());
}
if row.command.contains("WebKit.Networking") {
return Some("webkit-networking".to_string());
}
Some("webkit".to_string())
}
fn is_nearby_webkit_auxiliary(row: &ProcessRow, current_pid: u32) -> bool {
row.pid > current_pid
&& row.pid.saturating_sub(current_pid) <= WEBKIT_AUX_PID_WINDOW
&& row.command.contains("com.apple.WebKit.")
}
fn parse_process_row(line: &str) -> Option<ProcessRow> {
let mut fields = line.split_whitespace();
let pid = fields.next()?.parse().ok()?;
let parent_pid = fields.next()?.parse().ok()?;
let rss_kib = fields.next()?.parse().ok()?;
let command = fields.collect::<Vec<_>>().join(" ");
if command.is_empty() {
return None;
}
Some(ProcessRow {
pid,
parent_pid,
rss_kib,
command,
})
}
#[cfg(unix)]
fn read_process_rows() -> Result<Vec<ProcessRow>, String> {
let output = Command::new("ps")
.args(["-axo", "pid=,ppid=,rss=,command="])
.output()
.map_err(|error| format!("Failed to sample process memory: {error}"))?;
if !output.status.success() {
return Err("Failed to sample process memory with ps".to_string());
}
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(stdout.lines().filter_map(parse_process_row).collect())
}
#[cfg(not(unix))]
fn read_process_rows() -> Result<Vec<ProcessRow>, String> {
Err("Process memory snapshots are only implemented on Unix platforms".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_ps_rows_with_spaced_commands() {
let row = parse_process_row(" 42 1 1024 /System/WebKit WebContent").unwrap();
assert_eq!(row.pid, 42);
assert_eq!(row.parent_pid, 1);
assert_eq!(row.rss_kib, 1024);
assert_eq!(row.command, "/System/WebKit WebContent");
}
#[test]
fn classifies_nearby_webkit_auxiliaries() {
let row = ProcessRow {
pid: 120,
parent_pid: 1,
rss_kib: 10,
command: "/System/com.apple.WebKit.WebContent.xpc".to_string(),
};
assert_eq!(
classify_related_process(&row, 100),
Some("webkit-webcontent".to_string()),
);
}
#[test]
fn ignores_unrelated_webkit_auxiliaries() {
let row = ProcessRow {
pid: 900,
parent_pid: 1,
rss_kib: 10,
command: "/System/com.apple.WebKit.WebContent.xpc".to_string(),
};
assert_eq!(classify_related_process(&row, 100), None);
}
}

View File

@@ -4,6 +4,7 @@ mod folders;
mod git;
pub mod git_clone;
mod git_connect;
mod memory;
mod system;
mod vault;
mod version;
@@ -15,6 +16,7 @@ pub use delete::*;
pub use folders::*;
pub use git::*;
pub use git_connect::*;
pub use memory::*;
pub use system::*;
pub use vault::*;
pub use version::*;

View File

@@ -581,6 +581,7 @@ macro_rules! app_invoke_handler {
commands::copy_text_to_clipboard,
commands::read_text_from_clipboard,
commands::sync_mcp_bridge_vault,
commands::get_process_memory_snapshot,
commands::repair_vault,
commands::reinit_telemetry,
commands::list_views,

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 }
}

View File

@@ -0,0 +1,216 @@
import type { useCreateBlockNote } from '@blocknote/react'
import { preProcessWikilinks, injectWikilinks } from '../utils/wikilinks'
import { preProcessMathMarkdown, injectMathInBlocks } from '../utils/mathMarkdown'
import { preProcessMermaidMarkdown, injectMermaidInBlocks } from '../utils/mermaidMarkdown'
import { resolveImageUrls } from '../utils/vaultImages'
import { repairMalformedEditorBlocks } from './editorBlockRepair'
import {
blankParagraphBlocks,
extractEditorBody,
} from './editorTabContent'
import {
parseMarkdownBlocksWithFallback,
type MarkdownParseResult,
} from './editorMarkdownParseFallback'
import {
cacheParsedNoteBlocks,
readParsedNoteBlocks,
type EditorBlocks,
} from './editorParsedBlockCache'
export type { EditorBlocks }
type NotePath = string
type NoteContent = string
type MarkdownBody = string
type PreprocessedMarkdown = string
type VaultPath = string
export type CachedTabState = {
blocks: EditorBlocks
scrollTop: number
sourceContent: NoteContent
}
const TAB_STATE_CACHE_LIMIT = 24
export function cacheEditorState(
cache: Map<NotePath, CachedTabState>,
path: NotePath,
nextState: CachedTabState,
) {
if (cache.has(path)) cache.delete(path)
cache.set(path, nextState)
while (cache.size > TAB_STATE_CACHE_LIMIT) {
const oldestPath = cache.keys().next().value
if (!oldestPath) return
cache.delete(oldestPath)
}
}
export function cacheParsedEditorState(path: NotePath, nextState: CachedTabState, vaultPath?: VaultPath): void {
cacheParsedNoteBlocks({
path,
blocks: nextState.blocks,
scrollTop: nextState.scrollTop,
sourceContent: nextState.sourceContent,
vaultPath,
})
}
export function cacheResolvedEditorState(
cache: Map<NotePath, CachedTabState>,
path: NotePath,
nextState: CachedTabState,
vaultPath?: VaultPath,
): CachedTabState {
cacheEditorState(cache, path, nextState)
cacheParsedEditorState(path, nextState, vaultPath)
return nextState
}
function buildFastPathBlocks(options: { preprocessed: PreprocessedMarkdown }): EditorBlocks | null {
const { preprocessed } = options
const trimmed = preprocessed.trim()
if (!trimmed) return [{ type: 'paragraph', content: [] }]
if (trimmed === '#') return [emptyHeadingBlock(), { type: 'paragraph', content: [], children: [] }]
const h1OnlyMatch = trimmed.match(/^# (.+)$/)
if (!h1OnlyMatch) return null
return [
{
type: 'heading',
props: { level: 1, textColor: 'default', backgroundColor: 'default', textAlignment: 'left' },
content: [{ type: 'text', text: h1OnlyMatch[1], styles: {} }],
children: [],
},
{ type: 'paragraph', content: [], children: [] },
]
}
function emptyHeadingBlock(): Record<string, unknown> {
return {
type: 'heading',
props: { level: 1, textColor: 'default', backgroundColor: 'default', textAlignment: 'left' },
content: [],
children: [],
}
}
export function isBlankBodyContent(options: { content: NoteContent }): boolean {
const { content } = options
return extractEditorBody(content).trim() === ''
}
function extractBodyRemainderAfterEmptyH1(options: { content: NoteContent }): MarkdownBody | null {
const { content } = options
const body = extractEditorBody(content)
const [firstLine, secondLine, ...rest] = body.split('\n')
if (!firstLine) return null
const normalizedFirstLine = firstLine.trimEnd()
if (normalizedFirstLine !== '#' && normalizedFirstLine !== '# ') return null
return secondLine === '' ? rest.join('\n').trimStart() : [secondLine, ...rest].join('\n').trimStart()
}
export function startsWithEmptyHeading(options: { content: NoteContent }): boolean {
return extractBodyRemainderAfterEmptyH1(options) !== null
}
async function parseMarkdownBlocks(
editor: ReturnType<typeof useCreateBlockNote>,
preprocessed: PreprocessedMarkdown,
): Promise<EditorBlocks> {
const result = editor.tryParseMarkdownToBlocks(preprocessed)
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- tryParseMarkdownToBlocks returns sync or async BlockNote blocks
if (result && typeof (result as any).then === 'function') {
return (result as unknown as Promise<EditorBlocks>)
}
return result as EditorBlocks
}
function preProcessEditorMarkdown(markdown: MarkdownBody, vaultPath?: VaultPath): PreprocessedMarkdown {
const withMermaid = preProcessMermaidMarkdown({ markdown })
const withImages = vaultPath ? resolveImageUrls(withMermaid, vaultPath) : withMermaid
const withWikilinks = preProcessWikilinks(withImages)
return preProcessMathMarkdown({ markdown: withWikilinks })
}
function injectEditorMarkdownBlocks(blocks: EditorBlocks): EditorBlocks {
const withWikilinks = injectWikilinks(blocks)
const withMath = injectMathInBlocks(withWikilinks)
return injectMermaidInBlocks(withMath) as EditorBlocks
}
function repairParsedMarkdownBlocks(parsed: MarkdownParseResult): EditorBlocks {
const parseSafeBlocks = repairMalformedEditorBlocks(parsed.blocks) as EditorBlocks
if (parsed.usedSourceFallback) return parseSafeBlocks
return repairMalformedEditorBlocks(injectEditorMarkdownBlocks(parseSafeBlocks)) as EditorBlocks
}
export async function resolveBlocksForTarget(
options: {
editor: ReturnType<typeof useCreateBlockNote>
cache: Map<NotePath, CachedTabState>
targetPath: NotePath
content: NoteContent
vaultPath?: VaultPath
},
): Promise<CachedTabState> {
const { editor, cache, targetPath, content, vaultPath } = options
const cached = cache.get(targetPath)
if (cached?.sourceContent === content) return cached
const parsedCache = readParsedNoteBlocks({ path: targetPath, content, vaultPath })
if (parsedCache) {
return cacheResolvedEditorState(cache, targetPath, {
blocks: parsedCache.blocks,
scrollTop: parsedCache.scrollTop,
sourceContent: content,
}, vaultPath)
}
const body = extractEditorBody(content)
const preprocessed = preProcessEditorMarkdown(body, vaultPath)
const fastPathBlocks = buildFastPathBlocks({ preprocessed })
if (fastPathBlocks) {
return cacheResolvedEditorState(cache, targetPath, {
blocks: repairMalformedEditorBlocks(fastPathBlocks) as EditorBlocks,
scrollTop: 0,
sourceContent: content,
}, vaultPath)
}
const parsed = await parseMarkdownBlocksWithFallback({
parseMarkdownBlocks: markdown => parseMarkdownBlocks(editor, markdown),
preprocessed,
sourceMarkdown: body,
context: targetPath,
})
return cacheResolvedEditorState(cache, targetPath, {
blocks: repairParsedMarkdownBlocks(parsed),
scrollTop: 0,
sourceContent: content,
}, vaultPath)
}
export async function resolveEmptyHeadingBlocks(
editor: ReturnType<typeof useCreateBlockNote>,
content: NoteContent,
vaultPath?: VaultPath,
targetPath: NotePath = 'empty heading note',
): Promise<EditorBlocks | null> {
const remainder = extractBodyRemainderAfterEmptyH1({ content })
if (remainder === null) return null
if (!remainder.trim()) return [emptyHeadingBlock(), ...blankParagraphBlocks()] as EditorBlocks
const parsed = await parseMarkdownBlocksWithFallback({
parseMarkdownBlocks: markdown => parseMarkdownBlocks(editor, markdown),
preprocessed: preProcessEditorMarkdown(remainder, vaultPath),
sourceMarkdown: remainder,
context: targetPath,
})
return [emptyHeadingBlock(), ...repairParsedMarkdownBlocks(parsed)] as EditorBlocks
}

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, type MutableRefObject } from 'react'
export const RICH_EDITOR_CHANGE_DEBOUNCE_MS = 150
export const RICH_EDITOR_CHANGE_DEBOUNCE_MS = 500
export function useDebouncedEditorChange({
onFlush,

View File

@@ -30,7 +30,7 @@ interface ApplyHtmlStateToEditorOptions extends Omit<AppliedEditorContentCommit,
html: string
}
export function applyBlocksToEditor(options: ApplyBlocksToEditorOptions) {
export function applyBlocksToEditor(options: ApplyBlocksToEditorOptions): boolean {
const {
editor,
blocks,
@@ -53,14 +53,17 @@ export function applyBlocksToEditor(options: ApplyBlocksToEditorOptions) {
editor._tiptapEditor.commands.setContent(html)
} catch (err2) {
console.error('Fallback also failed:', err2)
suppressChangeRef.current = false
return false
}
} finally {
commitAppliedEditorContent(options)
}
commitAppliedEditorContent(options)
return true
}
export function applyBlankStateToEditor(options: ApplyBlankStateToEditorOptions) {
applyBlocksToEditor({ ...options, blocks: blankParagraphBlocks(), scrollTop: 0 })
export function applyBlankStateToEditor(options: ApplyBlankStateToEditorOptions): boolean {
return applyBlocksToEditor({ ...options, blocks: blankParagraphBlocks(), scrollTop: 0 })
}
export function applyHtmlStateToEditor(options: ApplyHtmlStateToEditorOptions) {

View File

@@ -0,0 +1,92 @@
export type EditorBlocks = unknown[]
export interface ParsedNoteBlockCacheEntry {
blocks: EditorBlocks
path: string
scrollTop: number
sourceContent: string
sourceBytes: number
vaultPath?: string
}
export const PARSED_NOTE_BLOCK_CACHE_LIMIT = 16
export const PARSED_NOTE_BLOCK_ENTRY_MAX_BYTES = 2 * 1024 * 1024
export const PARSED_NOTE_BLOCK_CACHE_MAX_SOURCE_BYTES = 12 * 1024 * 1024
const parsedBlockCache = new Map<string, ParsedNoteBlockCacheEntry>()
const sourceSizeEncoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null
function cacheKey(path: string, vaultPath?: string): string {
return `${vaultPath ?? ''}\0${path}`
}
function sourceBytes(content: string): number {
return sourceSizeEncoder ? sourceSizeEncoder.encode(content).byteLength : content.length
}
function cloneBlocks(blocks: EditorBlocks): EditorBlocks {
if (typeof structuredClone === 'function') return structuredClone(blocks)
return JSON.parse(JSON.stringify(blocks)) as EditorBlocks
}
function retainedSourceBytes(): number {
let totalBytes = 0
for (const entry of parsedBlockCache.values()) totalBytes += entry.sourceBytes
return totalBytes
}
function trimParsedBlockCache(): void {
while (
parsedBlockCache.size > PARSED_NOTE_BLOCK_CACHE_LIMIT
|| retainedSourceBytes() > PARSED_NOTE_BLOCK_CACHE_MAX_SOURCE_BYTES
) {
const oldestKey = parsedBlockCache.keys().next().value
if (!oldestKey) return
parsedBlockCache.delete(oldestKey)
}
}
export function cacheParsedNoteBlocks(entry: Omit<ParsedNoteBlockCacheEntry, 'sourceBytes'>): void {
const nextSourceBytes = sourceBytes(entry.sourceContent)
if (nextSourceBytes > PARSED_NOTE_BLOCK_ENTRY_MAX_BYTES) {
parsedBlockCache.delete(cacheKey(entry.path, entry.vaultPath))
return
}
const key = cacheKey(entry.path, entry.vaultPath)
if (parsedBlockCache.has(key)) parsedBlockCache.delete(key)
parsedBlockCache.set(key, {
...entry,
blocks: cloneBlocks(entry.blocks),
sourceBytes: nextSourceBytes,
})
trimParsedBlockCache()
}
export function readParsedNoteBlocks(options: {
content: string
path: string
vaultPath?: string
}): { blocks: EditorBlocks; scrollTop: number } | null {
const key = cacheKey(options.path, options.vaultPath)
const entry = parsedBlockCache.get(key)
if (!entry || entry.sourceContent !== options.content) return null
parsedBlockCache.delete(key)
parsedBlockCache.set(key, entry)
return {
blocks: cloneBlocks(entry.blocks),
scrollTop: entry.scrollTop,
}
}
export function clearParsedNoteBlockCache(path?: string): void {
if (!path) {
parsedBlockCache.clear()
return
}
for (const [key, entry] of parsedBlockCache) {
if (entry.path === path) parsedBlockCache.delete(key)
}
}

View File

@@ -0,0 +1,110 @@
import { useCallback, useEffect, useRef, type MutableRefObject } from 'react'
import type { NoteContentResolvedEvent } from './noteContentCache'
import { subscribeNoteContentResolved } from './noteContentCache'
export const PARSED_BLOCK_PRELOAD_MIN_BYTES = 32 * 1024
export const PARSED_BLOCK_PRELOAD_DELAY_MS = 1800
export const PARSED_BLOCK_PRELOAD_FOREGROUND_IDLE_MS = 1500
type PrepareParsedBlocks = (event: NoteContentResolvedEvent) => Promise<void>
interface ParsedBlockPreloadOptions {
activeTabPathRef: MutableRefObject<string | null>
editorMountedRef: MutableRefObject<boolean>
foregroundWorkAtRef: MutableRefObject<number>
prepareParsedBlocks: PrepareParsedBlocks
rawModeRef: MutableRefObject<boolean>
}
function canPreloadParsedBlocks(event: NoteContentResolvedEvent, activeTabPath: string | null): boolean {
const { entry } = event
if (!entry || entry.path === activeTabPath) return false
if ((entry.fileKind ?? 'markdown') !== 'markdown') return false
return entry.fileSize >= PARSED_BLOCK_PRELOAD_MIN_BYTES
}
function shouldDeferParsedPreload(options: {
editorMountedRef: MutableRefObject<boolean>
foregroundWorkAtRef: MutableRefObject<number>
rawModeRef: MutableRefObject<boolean>
}) {
const { editorMountedRef, foregroundWorkAtRef, rawModeRef } = options
if (!editorMountedRef.current || rawModeRef.current) return true
return Date.now() - foregroundWorkAtRef.current < PARSED_BLOCK_PRELOAD_FOREGROUND_IDLE_MS
}
function takeNextCandidate(
queue: Map<string, NoteContentResolvedEvent>,
activeTabPath: string | null,
): NoteContentResolvedEvent | null {
for (const candidate of queue.values()) {
queue.delete(candidate.path)
if (candidate.path !== activeTabPath) return candidate
}
return null
}
function clearScheduledTimer(timerRef: MutableRefObject<number | null>): void {
if (timerRef.current === null) return
window.clearTimeout(timerRef.current)
timerRef.current = null
}
export function useParsedBlockPreload({
activeTabPathRef,
editorMountedRef,
foregroundWorkAtRef,
prepareParsedBlocks,
rawModeRef,
}: ParsedBlockPreloadOptions) {
const queueRef = useRef<Map<string, NoteContentResolvedEvent>>(new Map())
const runningRef = useRef(false)
const timerRef = useRef<number | null>(null)
const runNextRef = useRef<() => void>(() => {})
const scheduleNext = useCallback(() => {
if (timerRef.current !== null) return
timerRef.current = window.setTimeout(() => {
timerRef.current = null
runNextRef.current()
}, PARSED_BLOCK_PRELOAD_DELAY_MS)
}, [])
const runNext = useCallback(async () => {
if (runningRef.current) return
if (shouldDeferParsedPreload({ editorMountedRef, foregroundWorkAtRef, rawModeRef })) {
scheduleNext()
return
}
const next = takeNextCandidate(queueRef.current, activeTabPathRef.current)
if (!next) return
runningRef.current = true
try {
await prepareParsedBlocks(next)
} catch (error) {
console.warn('Failed to preload parsed note blocks:', error)
} finally {
runningRef.current = false
if (queueRef.current.size > 0) scheduleNext()
}
}, [activeTabPathRef, editorMountedRef, foregroundWorkAtRef, prepareParsedBlocks, rawModeRef, scheduleNext])
useEffect(() => {
runNextRef.current = () => { void runNext() }
}, [runNext])
useEffect(() => {
const queue = queueRef.current
const unsubscribe = subscribeNoteContentResolved((event) => {
if (!canPreloadParsedBlocks(event, activeTabPathRef.current)) return
queue.set(event.path, event)
scheduleNext()
})
return () => {
unsubscribe()
clearScheduledTimer(timerRef)
queue.clear()
}
}, [activeTabPathRef, scheduleNext])
}

View File

@@ -0,0 +1,77 @@
import type { MutableRefObject } from 'react'
export interface SwapToken {
seq: number
path: string
content: string
}
interface SwapTab {
entry: { path: string }
content: string
}
export function createSwapToken(
swapSeqRef: MutableRefObject<number>,
path: string,
content: string,
): SwapToken {
const seq = swapSeqRef.current + 1
swapSeqRef.current = seq
return { seq, path, content }
}
export function invalidatePendingSwap(options: {
pendingSwapRef: MutableRefObject<(() => void) | null>
swapSeqRef: MutableRefObject<number>
}): void {
options.swapSeqRef.current += 1
options.pendingSwapRef.current = null
}
function activeTabMatchesSwapToken<Tab extends SwapTab>(options: {
tabsRef: MutableRefObject<Tab[]>
token: SwapToken
}): boolean {
const { tabsRef, token } = options
const activeTab = tabsRef.current.find(tab => tab.entry.path === token.path)
return activeTab?.content === token.content
}
function isCurrentSwapToken<Tab extends SwapTab>(options: {
prevActivePathRef: MutableRefObject<string | null>
swapSeqRef: MutableRefObject<number>
tabsRef: MutableRefObject<Tab[]>
token: SwapToken
}): boolean {
const {
prevActivePathRef,
swapSeqRef,
tabsRef,
token,
} = options
return swapSeqRef.current === token.seq
&& prevActivePathRef.current === token.path
&& activeTabMatchesSwapToken({ tabsRef, token })
}
export function shouldAbortSwap<Tab extends SwapTab>(options: {
prevActivePathRef: MutableRefObject<string | null>
suppressChangeRef: MutableRefObject<boolean>
swapSeqRef: MutableRefObject<number>
tabsRef: MutableRefObject<Tab[]>
token: SwapToken
}): boolean {
const {
prevActivePathRef,
suppressChangeRef,
swapSeqRef,
tabsRef,
token,
} = options
if (isCurrentSwapToken({ prevActivePathRef, swapSeqRef, tabsRef, token })) return false
if (swapSeqRef.current === token.seq) suppressChangeRef.current = false
return true
}

View File

@@ -0,0 +1,288 @@
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { VaultEntry } from '../types'
import { markNoteOpenTrace } from '../utils/noteOpenPerformance'
import { getNoteWindowParams, isNoteWindow } from '../utils/windowMode'
type NotePath = VaultEntry['path']
export interface NoteContentIdentity {
modifiedAt: number | null
fileSize: number | null
}
export interface NoteContentCacheEntry {
path: NotePath
promise: Promise<string>
value: string | null
byteSize: number
identity: NoteContentIdentity | null
}
export interface NoteContentResolvedEvent {
entry: VaultEntry | null
path: NotePath
content: string
}
type NoteContentResolvedListener = (event: NoteContentResolvedEvent) => void
const prefetchCache = new Map<string, NoteContentCacheEntry>()
const resolvedListeners = new Set<NoteContentResolvedListener>()
const contentSizeEncoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null
export const NOTE_CONTENT_CACHE_LIMIT = 48
export const NOTE_CONTENT_ENTRY_MAX_BYTES = 2 * 1024 * 1024
export const NOTE_CONTENT_CACHE_MAX_BYTES = 24 * 1024 * 1024
export function subscribeNoteContentResolved(listener: NoteContentResolvedListener): () => void {
resolvedListeners.add(listener)
return () => resolvedListeners.delete(listener)
}
function emitNoteContentResolved(event: NoteContentResolvedEvent): void {
for (const listener of resolvedListeners) {
try {
listener(event)
} catch (error) {
console.warn('Note content cache listener failed:', error)
}
}
}
function measureNoteContentBytes(content: string): number {
return contentSizeEncoder ? contentSizeEncoder.encode(content).byteLength : content.length
}
function noteContentIdentity(entry: VaultEntry): NoteContentIdentity {
return {
modifiedAt: entry.modifiedAt,
fileSize: entry.fileSize,
}
}
function isCompleteIdentity(identity: NoteContentIdentity | null): identity is NoteContentIdentity {
return identity !== null && identity.modifiedAt !== null && identity.fileSize !== null
}
function sameIdentity(left: NoteContentIdentity | null, right: NoteContentIdentity | null): boolean {
return isCompleteIdentity(left)
&& isCompleteIdentity(right)
&& left.modifiedAt === right.modifiedAt
&& left.fileSize === right.fileSize
}
function targetPath(target: string | VaultEntry): NotePath {
return typeof target === 'string' ? target : target.path
}
function targetEntry(target: string | VaultEntry): VaultEntry | null {
return typeof target === 'string' ? null : target
}
function targetIdentity(target: string | VaultEntry): NoteContentIdentity | null {
const entry = targetEntry(target)
return entry ? noteContentIdentity(entry) : null
}
function getRetainedPrefetchCacheBytes(): number {
let totalBytes = 0
for (const entry of prefetchCache.values()) totalBytes += entry.byteSize
return totalBytes
}
function dropOldestPrefetchEntry(): void {
const oldestPath = prefetchCache.keys().next().value
if (!oldestPath) return
prefetchCache.delete(oldestPath)
}
function trimPrefetchCache(): void {
while (
prefetchCache.size > NOTE_CONTENT_CACHE_LIMIT
|| getRetainedPrefetchCacheBytes() > NOTE_CONTENT_CACHE_MAX_BYTES
) {
if (prefetchCache.size === 0) return
dropOldestPrefetchEntry()
}
}
function rememberNoteContent(entry: NoteContentCacheEntry): NoteContentCacheEntry {
const { path } = entry
if (prefetchCache.has(path)) prefetchCache.delete(path)
prefetchCache.set(path, entry)
trimPrefetchCache()
return entry
}
function retainResolvedNoteContent(entry: NoteContentCacheEntry, content: string, sourceEntry: VaultEntry | null): void {
if (prefetchCache.get(entry.path) !== entry) return
const byteSize = measureNoteContentBytes(content)
if (byteSize > NOTE_CONTENT_ENTRY_MAX_BYTES) {
prefetchCache.delete(entry.path)
return
}
entry.value = content
entry.byteSize = byteSize
rememberNoteContent(entry)
emitNoteContentResolved({ entry: sourceEntry, path: entry.path, content })
}
function getNoteContentCommandPayload(path: string): { path: string; vaultPath?: string } {
if (!isNoteWindow()) return { path }
const noteWindowParams = getNoteWindowParams()
return noteWindowParams ? { path, vaultPath: noteWindowParams.vaultPath } : { path }
}
function getValidateNoteContentCommandPayload(path: string, content: string): { path: string; content: string; vaultPath?: string } {
return { ...getNoteContentCommandPayload(path), content }
}
function shouldReuseExistingRequest(existing: NoteContentCacheEntry, identity: NoteContentIdentity | null): boolean {
if (!isCompleteIdentity(identity) || !isCompleteIdentity(existing.identity)) return true
return sameIdentity(existing.identity, identity)
}
function requestNoteContent(target: string | VaultEntry): NoteContentCacheEntry {
const path = targetPath(target)
const sourceEntry = targetEntry(target)
const identity = targetIdentity(target)
const cacheEntry: NoteContentCacheEntry = {
path,
promise: Promise.resolve(''),
value: null,
byteSize: 0,
identity,
}
const commandPayload = getNoteContentCommandPayload(path)
const promise = (isTauri()
? invoke<string>('get_note_content', commandPayload)
: mockInvoke<string>('get_note_content', commandPayload)
)
.then((content) => {
retainResolvedNoteContent(cacheEntry, content, sourceEntry)
return content
})
.catch((err) => {
if (prefetchCache.get(path) === cacheEntry) prefetchCache.delete(path)
throw err
})
cacheEntry.promise = promise
return rememberNoteContent(cacheEntry)
}
export function prefetchNoteContent(target: string | VaultEntry): void {
const path = targetPath(target)
const identity = targetIdentity(target)
const existing = prefetchCache.get(path)
if (existing && shouldReuseExistingRequest(existing, identity)) return
void requestNoteContent(target).promise.catch((error) => {
if (isNoActiveVaultSelectedError(error) || isUnreadableNoteContentError(error)) return
console.warn('Failed to prefetch note content:', error)
})
}
export function cacheNoteContent(path: string, content: string, entry?: VaultEntry): void {
const byteSize = measureNoteContentBytes(content)
if (byteSize > NOTE_CONTENT_ENTRY_MAX_BYTES) {
prefetchCache.delete(path)
return
}
rememberNoteContent({
path,
promise: Promise.resolve(content),
value: content,
byteSize,
identity: entry ? noteContentIdentity(entry) : null,
})
emitNoteContentResolved({ entry: entry ?? null, path, content })
}
export function clearNoteContentCache(): void {
prefetchCache.clear()
}
export function hasResolvedCachedContent(entry: NoteContentCacheEntry | null): entry is NoteContentCacheEntry & { value: string } {
return !!entry && entry.value !== null
}
export function getCachedNoteContentEntry(path: string): NoteContentCacheEntry | null {
return prefetchCache.get(path) ?? null
}
async function validateCachedNoteContent(entry: NoteContentCacheEntry): Promise<boolean> {
if (entry.value === null) return false
const payload = getValidateNoteContentCommandPayload(entry.path, entry.value)
return isTauri()
? invoke<boolean>('validate_note_content', payload)
: mockInvoke<boolean>('validate_note_content', payload)
}
function canTrustCachedContentIdentity(entry: VaultEntry, cachedEntry: NoteContentCacheEntry): boolean {
return sameIdentity(noteContentIdentity(entry), cachedEntry.identity)
}
function canUseExistingContentRequest(target: VaultEntry, existing: NoteContentCacheEntry | undefined, forceFresh: boolean): existing is NoteContentCacheEntry {
if (forceFresh || !existing) return false
return shouldReuseExistingRequest(existing, noteContentIdentity(target))
}
async function loadNoteContent(target: VaultEntry, forceFresh = false): Promise<string> {
const existing = prefetchCache.get(target.path)
if (canUseExistingContentRequest(target, existing, forceFresh)) {
return existing.promise
}
return requestNoteContent(target).promise
}
async function loadCachedContentIfFresh(entry: VaultEntry, cachedEntry: NoteContentCacheEntry): Promise<string | null> {
if (cachedEntry.value === null) return null
if (canTrustCachedContentIdentity(entry, cachedEntry)) {
rememberNoteContent(cachedEntry)
return cachedEntry.value
}
markNoteOpenTrace(entry.path, 'freshnessCheckStart')
const isFresh = await validateCachedNoteContent(cachedEntry)
markNoteOpenTrace(entry.path, 'freshnessCheckEnd')
if (isFresh) {
rememberNoteContent(cachedEntry)
return cachedEntry.value
}
prefetchCache.delete(entry.path)
return null
}
export async function loadContentForOpen(options: {
entry: VaultEntry
forceReload: boolean
cachedEntry: NoteContentCacheEntry | null
}): Promise<string> {
const { entry, forceReload, cachedEntry } = options
if (!forceReload && hasResolvedCachedContent(cachedEntry)) {
const cachedContent = await loadCachedContentIfFresh(entry, cachedEntry)
if (cachedContent !== null) return cachedContent
}
return loadNoteContent(entry, forceReload || hasResolvedCachedContent(cachedEntry))
}
function errorMessage(error: unknown): string {
if (error instanceof Error) return error.message
if (typeof error === 'string') return error
return String(error)
}
export function isNoActiveVaultSelectedError(error: unknown): boolean {
return /no active vault selected/i.test(errorMessage(error))
}
export function isUnreadableNoteContentError(error: unknown): boolean {
return /not valid utf-8 text|invalid utf-8|stream did not contain valid utf-8/i.test(errorMessage(error))
}

View File

@@ -1,6 +1,6 @@
import { startTransition, useCallback, useRef } from 'react'
import { startTransition, useCallback, useRef, type MutableRefObject } from 'react'
import { useEditorSave } from './useEditorSave'
import { extractOutgoingLinks, extractSnippet, countWords } from '../utils/wikilinks'
import { extractOutgoingLinks, extractSnippet, countWords, splitFrontmatter } from '../utils/wikilinks'
import { deriveRawEditorEntryState } from './rawEditorEntryState'
import { deriveDisplayTitleState } from '../utils/noteTitle'
import { detectFrontmatterState } from '../utils/frontmatter'
@@ -8,6 +8,7 @@ import type { VaultEntry } from '../types'
import type { AppLocale } from '../lib/i18n'
const EMPTY_DERIVED_ENTRY_STATE_KEY = JSON.stringify(deriveRawEditorEntryState(''))
type UpdateEntry = (path: string, patch: Partial<VaultEntry>) => void
function shouldSyncFrontmatterState(content: string): boolean {
const frontmatterState = detectFrontmatterState(content)
@@ -15,6 +16,79 @@ function shouldSyncFrontmatterState(content: string): boolean {
return !(frontmatterState === 'none' && content.startsWith('---\n'))
}
function frontmatterSyncKey(content: string): string | null {
if (!shouldSyncFrontmatterState(content)) return null
return splitFrontmatter(content)[0]
}
function syncOutgoingLinks(options: {
content: string
path: string
prevLinksKeyRef: MutableRefObject<string>
updateEntry: UpdateEntry
}): void {
const { content, path, prevLinksKeyRef, updateEntry } = options
const links = content.includes('[[') ? extractOutgoingLinks(content) : []
const key = links.join('\0')
if (key === prevLinksKeyRef.current) return
prevLinksKeyRef.current = key
updateEntry(path, { outgoingLinks: links })
}
function resolveFrontmatterPatch(options: {
content: string
prevFmSourceRef: MutableRefObject<string | null>
}): Partial<VaultEntry> | null {
const { content, prevFmSourceRef } = options
const fmSource = frontmatterSyncKey(content)
if (fmSource === null || fmSource === prevFmSourceRef.current) return null
prevFmSourceRef.current = fmSource
return deriveRawEditorEntryState(content)
}
function syncFrontmatterMetadata(options: {
content: string
path: string
prevFmKeyRef: MutableRefObject<string>
prevFmSourceRef: MutableRefObject<string | null>
updateEntry: UpdateEntry
}): string | null {
const { content, path, prevFmKeyRef, prevFmSourceRef, updateEntry } = options
const frontmatterPatch = resolveFrontmatterPatch({ content, prevFmSourceRef })
if (!frontmatterPatch) return null
const frontmatterTitle = typeof frontmatterPatch.title === 'string' ? frontmatterPatch.title : null
const fmPatch = { ...frontmatterPatch }
delete fmPatch.title
const fmKey = JSON.stringify(fmPatch)
if (fmKey !== prevFmKeyRef.current) {
prevFmKeyRef.current = fmKey
updateEntry(path, fmPatch)
}
return frontmatterTitle
}
function syncDisplayTitle(options: {
content: string
frontmatterTitle: string | null
path: string
prevTitleKeyRef: MutableRefObject<string>
updateEntry: UpdateEntry
}): void {
const { content, frontmatterTitle, path, prevTitleKeyRef, updateEntry } = options
const filename = path.split('/').pop() ?? path
const titlePatch = deriveDisplayTitleState({ content, filename, frontmatterTitle })
const titleKey = JSON.stringify(titlePatch)
if (titleKey === prevTitleKeyRef.current) return
prevTitleKeyRef.current = titleKey
startTransition(() => {
updateEntry(path, titlePatch)
})
}
export function useEditorSaveWithLinks(config: {
updateEntry: (path: string, patch: Partial<VaultEntry>) => void
setTabs: Parameters<typeof useEditorSave>[0]['setTabs']
@@ -39,41 +113,26 @@ export function useEditorSaveWithLinks(config: {
const editor = useEditorSave({ ...config, updateVaultContent: saveContent })
const { handleContentChange: rawOnChange } = editor
const prevLinksKeyRef = useRef('')
const prevFmSourceRef = useRef<string | null>(null)
const prevFmKeyRef = useRef(EMPTY_DERIVED_ENTRY_STATE_KEY)
const prevTitleKeyRef = useRef('')
const handleContentChange = useCallback((path: string, content: string) => {
rawOnChange(path, content)
const links = extractOutgoingLinks(content)
const key = links.join('\0')
if (key !== prevLinksKeyRef.current) {
prevLinksKeyRef.current = key
updateEntry(path, { outgoingLinks: links })
}
const frontmatterPatch = shouldSyncFrontmatterState(content)
? deriveRawEditorEntryState(content)
: null
const filename = path.split('/').pop() ?? path
const titlePatch = deriveDisplayTitleState({
syncOutgoingLinks({ content, path, prevLinksKeyRef, updateEntry })
const frontmatterTitle = syncFrontmatterMetadata({
content,
filename,
frontmatterTitle: typeof frontmatterPatch?.title === 'string' ? frontmatterPatch.title : null,
path,
prevFmKeyRef,
prevFmSourceRef,
updateEntry,
})
syncDisplayTitle({
content,
frontmatterTitle,
path,
prevTitleKeyRef,
updateEntry,
})
if (frontmatterPatch) {
const fmPatch = { ...frontmatterPatch }
delete fmPatch.title
const fmKey = JSON.stringify(fmPatch)
if (fmKey !== prevFmKeyRef.current) {
prevFmKeyRef.current = fmKey
updateEntry(path, fmPatch)
}
}
const titleKey = JSON.stringify(titlePatch)
if (titleKey !== prevTitleKeyRef.current) {
prevTitleKeyRef.current = titleKey
startTransition(() => {
updateEntry(path, titlePatch)
})
}
}, [rawOnChange, updateEntry])
return { ...editor, handleContentChange }
}

View File

@@ -3,6 +3,7 @@ import { renderHook, act } from '@testing-library/react'
import { extractEditorBody, getH1TextFromBlocks, replaceTitleInFrontmatter, RICH_EDITOR_CHANGE_DEBOUNCE_MS, useEditorTabSwap } from './useEditorTabSwap'
import { normalizeParsedImageBlocks } from './editorTabContent'
import { cacheNoteContent, clearPrefetchCache } from './useTabManagement'
import { cacheParsedNoteBlocks, clearParsedNoteBlockCache } from './editorParsedBlockCache'
describe('extractEditorBody', () => {
it('strips frontmatter and preserves H1 heading for new note content', () => {
@@ -199,6 +200,10 @@ describe('normalizeParsedImageBlocks', () => {
const blocksA = [{ type: 'paragraph', content: [{ type: 'text', text: 'A' }] }]
function makeTextParagraphBlock(text: string) {
return { type: 'paragraph', content: [{ type: 'text', text, styles: {} }], children: [] }
}
function makeTab(path: string, title: string) {
return {
entry: { path, title, filename: `${title}.md`, type: 'Note', status: 'Active', aliases: [], isA: '' } as never,
@@ -271,6 +276,14 @@ async function flushEditorTick() {
await act(() => new Promise<void>((resolve) => setTimeout(resolve, 0)))
}
function createDeferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((res) => {
resolve = res
})
return { promise, resolve }
}
function installEditorDomSpies(scrollTop = 0) {
const scrollEl = { scrollTop }
const frameSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
@@ -335,7 +348,10 @@ async function createSwapHarness(options: {
}
describe('useEditorTabSwap raw mode sync', () => {
afterEach(() => { vi.restoreAllMocks() })
afterEach(() => {
clearParsedNoteBlockCache()
vi.restoreAllMocks()
})
it('swaps in the new note when the path updates before tabs catch up', async () => {
const tabA = makeTab('a.md', 'Note A')
@@ -474,7 +490,34 @@ describe('useEditorTabSwap raw mode sync', () => {
expect(mockEditor.replaceBlocks).toHaveBeenCalled()
})
it('prepares prefetched note blocks before the note is opened', async () => {
it('uses parsed block cache for a note that was warmed before opening', async () => {
const tabA = makeTab('a.md', 'Note A')
const tabB = makeTab('b.md', 'Note B')
const warmedBlocks = [makeTextParagraphBlock('Warmed body')]
cacheParsedNoteBlocks({
path: tabB.entry.path,
sourceContent: tabB.content,
blocks: warmedBlocks,
scrollTop: 0,
})
const { mockEditor, rerenderWith } = await createSwapHarness({
initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false },
})
mockEditor.tryParseMarkdownToBlocks.mockClear()
mockEditor.replaceBlocks.mockClear()
await rerenderWith({ tabs: [tabB], activeTabPath: 'b.md' })
expect(mockEditor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
expect(mockEditor.replaceBlocks.mock.calls[0][1]).toEqual([
expect.objectContaining({
content: [{ type: 'text', text: 'Warmed body', styles: {} }],
}),
])
})
it('does not preparse prefetched note blocks in the background', async () => {
clearPrefetchCache()
const tabA = makeTab('a.md', 'Note A')
const tabB = {
@@ -488,16 +531,15 @@ describe('useEditorTabSwap raw mode sync', () => {
mockEditor.tryParseMarkdownToBlocks.mockClear()
cacheNoteContent('b.md', tabB.content)
await act(() => new Promise<void>((resolve) => setTimeout(resolve, 100)))
await flushEditorTick()
expect(mockEditor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
await rerenderWith({ tabs: [tabB], activeTabPath: 'b.md' })
expect(mockEditor.tryParseMarkdownToBlocks).toHaveBeenCalledWith(
expect.stringContaining('Prepared body.'),
)
mockEditor.tryParseMarkdownToBlocks.mockClear()
await rerenderWith({ tabs: [tabB], activeTabPath: 'b.md' })
expect(mockEditor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
clearPrefetchCache()
})
@@ -555,6 +597,49 @@ describe('useEditorTabSwap raw mode sync', () => {
expect(mockEditor.replaceBlocks).toHaveBeenCalled()
})
it('ignores stale same-path parse results when tab content refreshes', async () => {
const staleParse = createDeferred<unknown[]>()
const freshParse = createDeferred<unknown[]>()
const tabA = makeTab('a.md', 'Note A')
const refreshedTabA = {
...tabA,
content: '---\ntitle: Note A\n---\n\n# Note A\n\nFresh filesystem content.',
}
const staleBlocks = [makeTextParagraphBlock('Stale content')]
const freshBlocks = [makeTextParagraphBlock('Fresh filesystem content')]
const { mockEditor, rerenderWith } = await createSwapHarness({
initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false },
setupEditor: (editor) => {
editor.tryParseMarkdownToBlocks
.mockReturnValueOnce(staleParse.promise)
.mockReturnValueOnce(freshParse.promise)
},
})
await rerenderWith({ tabs: [refreshedTabA], activeTabPath: 'a.md' })
mockEditor.replaceBlocks.mockClear()
await act(async () => {
staleParse.resolve(staleBlocks)
await Promise.resolve()
})
expect(mockEditor.replaceBlocks).not.toHaveBeenCalled()
await act(async () => {
freshParse.resolve(freshBlocks)
await Promise.resolve()
})
expect(mockEditor.replaceBlocks).toHaveBeenCalledTimes(1)
expect(mockEditor.replaceBlocks.mock.calls[0][1]).toEqual([
expect.objectContaining({
content: [{ type: 'text', text: 'Fresh filesystem content', styles: {} }],
}),
])
})
it('re-parses when the active tab content changes without a path change', async () => {
const tabA = makeTab('a.md', 'Note A')
const refreshedTabA = {

View File

@@ -1,15 +1,12 @@
import { useCallback, useEffect, useRef, type MutableRefObject } from 'react'
import type { useCreateBlockNote } from '@blocknote/react'
import type { VaultEntry } from '../types'
import { splitFrontmatter, preProcessWikilinks, injectWikilinks, restoreWikilinksInBlocks } from '../utils/wikilinks'
import { splitFrontmatter, restoreWikilinksInBlocks } from '../utils/wikilinks'
import { compactMarkdown } from '../utils/compact-markdown'
import { injectMathInBlocks, preProcessMathMarkdown } from '../utils/mathMarkdown'
import { injectMermaidInBlocks, preProcessMermaidMarkdown, serializeMermaidAwareBlocks } from '../utils/mermaidMarkdown'
import { serializeMermaidAwareBlocks } from '../utils/mermaidMarkdown'
import { failNoteOpenTrace, finishNoteOpenTrace } from '../utils/noteOpenPerformance'
import { resolveImageUrls, portableImageUrls } from '../utils/vaultImages'
import { getResolvedCachedNoteContent, NOTE_CONTENT_CACHE_RESOLVED_EVENT } from './useTabManagement'
import { portableImageUrls } from '../utils/vaultImages'
import { useEditorMountState, useLatestRef } from './editorTabSwapLifecycle'
import { usePreparedNotePreload } from './usePreparedNotePreload'
import {
applyBlankStateToEditor,
applyBlocksToEditor,
@@ -21,7 +18,6 @@ import {
flushBeforeRawMode,
useDebouncedEditorChange,
} from './editorChangeDebounce'
import { repairMalformedEditorBlocks } from './editorBlockRepair'
import {
blankParagraphBlocks,
extractEditorBody,
@@ -32,9 +28,22 @@ import {
} from './editorTabContent'
import { clearEditorDomSelection, EDITOR_CONTAINER_SELECTOR } from './editorDomSelection'
import {
parseMarkdownBlocksWithFallback,
type MarkdownParseResult,
} from './editorMarkdownParseFallback'
cacheEditorState,
cacheParsedEditorState,
cacheResolvedEditorState,
isBlankBodyContent,
resolveBlocksForTarget,
resolveEmptyHeadingBlocks,
startsWithEmptyHeading,
type CachedTabState,
} from './editorBlockResolution'
import {
createSwapToken,
invalidatePendingSwap,
shouldAbortSwap,
type SwapToken,
} from './editorSwapToken'
import { useParsedBlockPreload } from './editorParsedBlockPreload'
export { extractEditorBody, getH1TextFromBlocks, replaceTitleInFrontmatter } from './editorTabContent'
export { RICH_EDITOR_CHANGE_DEBOUNCE_MS } from './editorChangeDebounce'
@@ -43,11 +52,7 @@ interface Tab {
content: string
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- BlockNote block arrays
type EditorBlocks = any[]
type CachedTabState = { blocks: EditorBlocks; scrollTop: number; sourceContent: string }
type PendingLocalContent = { path: string; content: string }
const TAB_STATE_CACHE_LIMIT = 24
interface TabSwapState {
cache: Map<string, CachedTabState>
@@ -74,9 +79,11 @@ interface RunTabSwapEffectOptions {
editor: ReturnType<typeof useCreateBlockNote>
rawMode?: boolean
tabCacheRef: MutableRefObject<Map<string, CachedTabState>>
tabsRef: MutableRefObject<Tab[]>
prevActivePathRef: MutableRefObject<string | null>
editorMountedRef: MutableRefObject<boolean>
pendingSwapRef: MutableRefObject<(() => void) | null>
swapSeqRef: MutableRefObject<number>
prevRawModeRef: MutableRefObject<boolean>
rawSwapPendingRef: MutableRefObject<boolean>
suppressChangeRef: MutableRefObject<boolean>
@@ -90,6 +97,8 @@ interface UseTabSwapEffectOptions extends Omit<RunTabSwapEffectOptions, 'vaultPa
vaultPathRef: MutableRefObject<string | undefined>
}
type ParsedBlockPreloadEvent = { path: string; content: string }
function signalEditorTabSwapped(path: string): void {
window.dispatchEvent(new CustomEvent('laputa:editor-tab-swapped', {
detail: { path },
@@ -102,181 +111,6 @@ function readEditorScrollTop(): number {
return scrollEl?.scrollTop ?? 0
}
function cacheEditorState(
cache: Map<string, CachedTabState>,
path: string,
nextState: CachedTabState,
) {
if (cache.has(path)) cache.delete(path)
cache.set(path, nextState)
while (cache.size > TAB_STATE_CACHE_LIMIT) {
const oldestPath = cache.keys().next().value
if (!oldestPath) return
cache.delete(oldestPath)
}
}
function buildFastPathBlocks(options: { preprocessed: string }): EditorBlocks | null {
const { preprocessed } = options
const trimmed = preprocessed.trim()
if (!trimmed) {
return [{ type: 'paragraph', content: [] }]
}
if (trimmed === '#') {
return [
{ type: 'heading', props: { level: 1, textColor: 'default', backgroundColor: 'default', textAlignment: 'left' }, content: [], children: [] },
{ type: 'paragraph', content: [], children: [] },
]
}
const h1OnlyMatch = trimmed.match(/^# (.+)$/)
if (!h1OnlyMatch) return null
return [
{ type: 'heading', props: { level: 1, textColor: 'default', backgroundColor: 'default', textAlignment: 'left' }, content: [{ type: 'text', text: h1OnlyMatch[1], styles: {} }], children: [] },
{ type: 'paragraph', content: [], children: [] },
]
}
function emptyHeadingBlock(): Record<string, unknown> {
return {
type: 'heading',
props: { level: 1, textColor: 'default', backgroundColor: 'default', textAlignment: 'left' },
content: [],
children: [],
}
}
function isBlankBodyContent(options: { content: string }): boolean {
const { content } = options
return extractEditorBody(content).trim() === ''
}
function extractBodyRemainderAfterEmptyH1(options: { content: string }): string | null {
const { content } = options
const body = extractEditorBody(content)
const [firstLine, secondLine, ...rest] = body.split('\n')
if (!firstLine) return null
const normalizedFirstLine = firstLine.trimEnd()
if (normalizedFirstLine !== '#' && normalizedFirstLine !== '# ') return null
if (secondLine === '') {
return rest.join('\n').trimStart()
}
return [secondLine, ...rest].join('\n').trimStart()
}
async function parseMarkdownBlocks(
editor: ReturnType<typeof useCreateBlockNote>,
preprocessed: string,
): Promise<EditorBlocks> {
const result = editor.tryParseMarkdownToBlocks(preprocessed)
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- tryParseMarkdownToBlocks returns sync or async BlockNote blocks
if (result && typeof (result as any).then === 'function') {
return (result as unknown as Promise<EditorBlocks>)
}
return result as EditorBlocks
}
function preProcessEditorMarkdown(markdown: string, vaultPath?: string): string {
const withMermaid = preProcessMermaidMarkdown({ markdown })
const withImages = vaultPath ? resolveImageUrls(withMermaid, vaultPath) : withMermaid
const withWikilinks = preProcessWikilinks(withImages)
return preProcessMathMarkdown({ markdown: withWikilinks })
}
function injectEditorMarkdownBlocks(blocks: EditorBlocks): EditorBlocks {
const withWikilinks = injectWikilinks(blocks)
const withMath = injectMathInBlocks(withWikilinks)
return injectMermaidInBlocks(withMath) as EditorBlocks
}
function repairParsedMarkdownBlocks(parsed: MarkdownParseResult): EditorBlocks {
const parseSafeBlocks = repairMalformedEditorBlocks(parsed.blocks) as EditorBlocks
if (parsed.usedSourceFallback) return parseSafeBlocks
return repairMalformedEditorBlocks(injectEditorMarkdownBlocks(parseSafeBlocks)) as EditorBlocks
}
async function resolveBlocksForTarget(
options: {
editor: ReturnType<typeof useCreateBlockNote>
cache: Map<string, CachedTabState>
targetPath: string
content: string
vaultPath?: string
},
): Promise<CachedTabState> {
const { editor, cache, targetPath, content, vaultPath } = options
const cached = cache.get(targetPath)
if (cached?.sourceContent === content) return cached
const body = extractEditorBody(content)
const preprocessed = preProcessEditorMarkdown(body, vaultPath)
const fastPathBlocks = buildFastPathBlocks({ preprocessed })
if (fastPathBlocks) {
const nextState = {
blocks: repairMalformedEditorBlocks(fastPathBlocks) as EditorBlocks,
scrollTop: 0,
sourceContent: content,
}
cacheEditorState(cache, targetPath, nextState)
return nextState
}
const parsed = await parseMarkdownBlocksWithFallback({
parseMarkdownBlocks: markdown => parseMarkdownBlocks(editor, markdown),
preprocessed,
sourceMarkdown: body,
context: targetPath,
})
const nextState = {
blocks: repairParsedMarkdownBlocks(parsed),
scrollTop: 0,
sourceContent: content,
}
cacheEditorState(cache, targetPath, nextState)
return nextState
}
function shouldPrepareCachedContent(cache: Map<string, CachedTabState>, path: string, content: string): boolean {
return cache.get(path)?.sourceContent !== content
}
async function prepareCachedNoteContent(options: {
editor: ReturnType<typeof useCreateBlockNote>
cache: Map<string, CachedTabState>
path: string
vaultPath?: string
}): Promise<void> {
const { editor, cache, path, vaultPath } = options
const cached = getResolvedCachedNoteContent(path)
if (!cached || !shouldPrepareCachedContent(cache, path, cached.content)) return
await resolveBlocksForTarget({ editor, cache, targetPath: path, content: cached.content, vaultPath })
}
async function resolveEmptyHeadingBlocks(
editor: ReturnType<typeof useCreateBlockNote>,
content: string,
vaultPath?: string,
targetPath = 'empty heading note',
): Promise<EditorBlocks | null> {
const remainder = extractBodyRemainderAfterEmptyH1({ content })
if (remainder === null) return null
if (!remainder.trim()) return [emptyHeadingBlock(), ...blankParagraphBlocks()] as EditorBlocks
const parsed = await parseMarkdownBlocksWithFallback({
parseMarkdownBlocks: markdown => parseMarkdownBlocks(editor, markdown),
preprocessed: preProcessEditorMarkdown(remainder, vaultPath),
sourceMarkdown: remainder,
context: targetPath,
})
return [emptyHeadingBlock(), ...repairParsedMarkdownBlocks(parsed)] as EditorBlocks
}
function findActiveTab(options: {
tabs: Tab[]
activeTabPath: string | null
@@ -380,11 +214,11 @@ function useEditorChangeHandler(options: {
const [frontmatter] = splitFrontmatter(tab.content)
const nextContent = `${frontmatter}${bodyMarkdown}`
pendingLocalContentRef.current = { path, content: nextContent }
cacheEditorState(tabCacheRef.current, path, {
cacheResolvedEditorState(tabCacheRef.current, path, {
blocks,
scrollTop: readEditorScrollTop(),
sourceContent: nextContent,
})
}, vaultPathRef.current)
onContentChangeRef.current?.(path, nextContent)
}, [editor, editorContentPathRef, onContentChangeRef, pendingLocalContentRef, prevActivePathRef, tabCacheRef, tabsRef, vaultPathRef])
@@ -752,7 +586,7 @@ function applyBlankTabState(options: {
editor: ReturnType<typeof useCreateBlockNote>
suppressChangeRef: MutableRefObject<boolean>
editorContentPathRef: EditorContentPathRef
}) {
}): boolean {
const {
cache,
targetPath,
@@ -767,8 +601,12 @@ function applyBlankTabState(options: {
scrollTop: 0,
sourceContent: content,
})
applyBlankStateToEditor({ editor, suppressChangeRef, editorContentPathRef, targetPath })
if (!applyBlankStateToEditor({ editor, suppressChangeRef, editorContentPathRef, targetPath })) {
return false
}
signalTabSwap({ path: targetPath })
return true
}
function scheduleEmptyHeadingSwap(options: {
@@ -778,6 +616,9 @@ function scheduleEmptyHeadingSwap(options: {
prevActivePathRef: MutableRefObject<string | null>
suppressChangeRef: MutableRefObject<boolean>
editorContentPathRef: EditorContentPathRef
swapSeqRef: MutableRefObject<number>
tabsRef: MutableRefObject<Tab[]>
token: SwapToken
vaultPath?: string
}) {
const {
@@ -787,19 +628,23 @@ function scheduleEmptyHeadingSwap(options: {
prevActivePathRef,
suppressChangeRef,
editorContentPathRef,
swapSeqRef,
tabsRef,
token,
vaultPath,
} = options
if (extractBodyRemainderAfterEmptyH1({ content }) === null) return false
if (!startsWithEmptyHeading({ content })) return false
void resolveEmptyHeadingBlocks(editor, content, vaultPath, targetPath)
.then((blocks) => {
if (prevActivePathRef.current !== targetPath || !blocks) return
applyBlocksToEditor({ editor, blocks, scrollTop: 0, suppressChangeRef, editorContentPathRef, targetPath })
if (!blocks || shouldAbortSwap({ prevActivePathRef, suppressChangeRef, swapSeqRef, tabsRef, token })) return
cacheParsedEditorState(targetPath, { blocks, scrollTop: 0, sourceContent: content }, vaultPath)
if (!applyBlocksToEditor({ editor, blocks, scrollTop: 0, suppressChangeRef, editorContentPathRef, targetPath })) return
signalTabSwap({ path: targetPath })
})
.catch((err: unknown) => {
suppressChangeRef.current = false
if (swapSeqRef.current === token.seq) suppressChangeRef.current = false
console.error('Failed to render empty heading state:', err)
failNoteOpenTrace(targetPath, 'empty-heading-swap-failed')
})
@@ -815,6 +660,9 @@ function scheduleParsedBlockSwap(options: {
prevActivePathRef: MutableRefObject<string | null>
suppressChangeRef: MutableRefObject<boolean>
editorContentPathRef: EditorContentPathRef
swapSeqRef: MutableRefObject<number>
tabsRef: MutableRefObject<Tab[]>
token: SwapToken
vaultPath?: string
}) {
const {
@@ -825,17 +673,20 @@ function scheduleParsedBlockSwap(options: {
prevActivePathRef,
suppressChangeRef,
editorContentPathRef,
swapSeqRef,
tabsRef,
token,
vaultPath,
} = options
void resolveBlocksForTarget({ editor, cache, targetPath, content, vaultPath })
.then(({ blocks, scrollTop }) => {
if (prevActivePathRef.current !== targetPath) return
applyBlocksToEditor({ editor, blocks, scrollTop, suppressChangeRef, editorContentPathRef, targetPath })
if (shouldAbortSwap({ prevActivePathRef, suppressChangeRef, swapSeqRef, tabsRef, token })) return
if (!applyBlocksToEditor({ editor, blocks, scrollTop, suppressChangeRef, editorContentPathRef, targetPath })) return
signalTabSwap({ path: targetPath })
})
.catch((err: unknown) => {
suppressChangeRef.current = false
if (swapSeqRef.current === token.seq) suppressChangeRef.current = false
console.error('Failed to parse/swap editor content:', err)
failNoteOpenTrace(targetPath, 'parsed-swap-failed')
})
@@ -848,6 +699,8 @@ function scheduleTabSwap(options: {
activeTab: Tab
clearDomSelection: boolean
pendingSwapRef: MutableRefObject<(() => void) | null>
swapSeqRef: MutableRefObject<number>
tabsRef: MutableRefObject<Tab[]>
prevActivePathRef: MutableRefObject<string | null>
rawSwapPendingRef: MutableRefObject<boolean>
suppressChangeRef: MutableRefObject<boolean>
@@ -861,6 +714,8 @@ function scheduleTabSwap(options: {
activeTab,
clearDomSelection,
pendingSwapRef,
swapSeqRef,
tabsRef,
prevActivePathRef,
rawSwapPendingRef,
suppressChangeRef,
@@ -868,9 +723,11 @@ function scheduleTabSwap(options: {
vaultPath,
} = options
const token = createSwapToken(swapSeqRef, targetPath, activeTab.content)
suppressChangeRef.current = true
const doSwap = () => {
if (shouldAbortSwap({ prevActivePathRef, suppressChangeRef, swapSeqRef, tabsRef, token })) return
if (clearStaleSwap({ targetPath, prevActivePathRef, suppressChangeRef })) return
rawSwapPendingRef.current = false
if (clearDomSelection) clearEditorDomSelection()
@@ -894,6 +751,9 @@ function scheduleTabSwap(options: {
prevActivePathRef,
suppressChangeRef,
editorContentPathRef,
swapSeqRef,
tabsRef,
token,
vaultPath,
})) {
return
@@ -907,6 +767,9 @@ function scheduleTabSwap(options: {
prevActivePathRef,
suppressChangeRef,
editorContentPathRef,
swapSeqRef,
tabsRef,
token,
vaultPath,
})
}
@@ -1005,9 +868,11 @@ function runTabSwapEffect(options: RunTabSwapEffectOptions) {
editor,
rawMode,
tabCacheRef,
tabsRef,
prevActivePathRef,
editorMountedRef,
pendingSwapRef,
swapSeqRef,
prevRawModeRef,
rawSwapPendingRef,
suppressChangeRef,
@@ -1026,6 +891,7 @@ function runTabSwapEffect(options: RunTabSwapEffectOptions) {
prevActivePathRef,
rawModeJustEnded,
})
if (state.pathChanged) invalidatePendingSwap({ pendingSwapRef, swapSeqRef })
flushBeforePathChange({ pathChanged: state.pathChanged, flushPendingEditorChange })
if (shouldSkipScheduledTabSwap({
@@ -1050,6 +916,8 @@ function runTabSwapEffect(options: RunTabSwapEffectOptions) {
activeTab: state.activeTab,
clearDomSelection: state.pathChanged,
pendingSwapRef,
swapSeqRef,
tabsRef,
prevActivePathRef,
rawSwapPendingRef,
suppressChangeRef,
@@ -1065,9 +933,11 @@ function useTabSwapEffect(options: UseTabSwapEffectOptions) {
editor,
rawMode,
tabCacheRef,
tabsRef,
prevActivePathRef,
editorMountedRef,
pendingSwapRef,
swapSeqRef,
prevRawModeRef,
rawSwapPendingRef,
suppressChangeRef,
@@ -1084,9 +954,11 @@ function useTabSwapEffect(options: UseTabSwapEffectOptions) {
editor,
rawMode,
tabCacheRef,
tabsRef,
editorMountedRef,
prevActivePathRef,
pendingSwapRef,
swapSeqRef,
prevRawModeRef,
rawSwapPendingRef,
suppressChangeRef,
@@ -1100,6 +972,7 @@ function useTabSwapEffect(options: UseTabSwapEffectOptions) {
editor,
editorMountedRef,
pendingSwapRef,
swapSeqRef,
prevActivePathRef,
prevRawModeRef,
rawMode,
@@ -1107,6 +980,7 @@ function useTabSwapEffect(options: UseTabSwapEffectOptions) {
suppressChangeRef,
editorContentPathRef,
tabCacheRef,
tabsRef,
tabs,
pendingLocalContentRef,
vaultPathRef,
@@ -1114,6 +988,38 @@ function useTabSwapEffect(options: UseTabSwapEffectOptions) {
])
}
function useForegroundWorkTracker(
activeTabPath: string | null,
handleEditorChange: () => void,
) {
const foregroundWorkAtRef = useRef(0)
useEffect(() => {
foregroundWorkAtRef.current = Date.now()
}, [activeTabPath])
const handleForegroundEditorChange = useCallback(() => {
foregroundWorkAtRef.current = Date.now()
handleEditorChange()
}, [handleEditorChange])
return { foregroundWorkAtRef, handleForegroundEditorChange }
}
function usePrepareParsedBlocks(options: {
editor: ReturnType<typeof useCreateBlockNote>
tabCacheRef: MutableRefObject<Map<string, CachedTabState>>
vaultPathRef: MutableRefObject<string | undefined>
}) {
const { editor, tabCacheRef, vaultPathRef } = options
return useCallback(async (event: ParsedBlockPreloadEvent) => {
await resolveBlocksForTarget({
editor,
cache: tabCacheRef.current,
targetPath: event.path,
content: event.content,
vaultPath: vaultPathRef.current,
})
}, [editor, tabCacheRef, vaultPathRef])
}
/**
* Manages the tab content-swap machinery for the BlockNote editor.
*
@@ -1130,10 +1036,13 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
const tabCacheRef = useRef<Map<string, CachedTabState>>(new Map())
const pendingLocalContentRef = useRef<PendingLocalContent | null>(null)
const prevActivePathRef = useRef<string | null>(null)
const activeTabPathLatestRef = useLatestRef(activeTabPath)
const editorContentPathRef = useRef<string | null>(null)
const editorMountedRef = useRef(false)
const pendingSwapRef = useRef<(() => void) | null>(null)
const swapSeqRef = useRef(0)
const prevRawModeRef = useRef(!!rawMode)
const rawModeLatestRef = useLatestRef(!!rawMode)
const rawSwapPendingRef = useRef(false)
const suppressChangeRef = useRef(false)
const onContentChangeRef = useLatestRef(onContentChange)
@@ -1150,18 +1059,27 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
pendingLocalContentRef,
vaultPathRef,
})
const preparePreloadedPath = useCallback((path: string) => prepareCachedNoteContent({ editor, cache: tabCacheRef.current, path, vaultPath: vaultPathRef.current }), [editor, tabCacheRef, vaultPathRef])
const { foregroundWorkAtRef, handleForegroundEditorChange } = useForegroundWorkTracker(activeTabPath, handleEditorChange)
const prepareParsedBlocks = usePrepareParsedBlocks({ editor, tabCacheRef, vaultPathRef })
useEditorMountState(editor, editorMountedRef, pendingSwapRef)
useParsedBlockPreload({
activeTabPathRef: activeTabPathLatestRef,
editorMountedRef,
foregroundWorkAtRef,
prepareParsedBlocks,
rawModeRef: rawModeLatestRef,
})
useTabSwapEffect({
tabs,
activeTabPath,
editor,
rawMode,
tabCacheRef,
tabsRef,
prevActivePathRef,
editorMountedRef,
pendingSwapRef,
swapSeqRef,
prevRawModeRef,
rawSwapPendingRef,
suppressChangeRef,
@@ -1170,12 +1088,6 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
vaultPathRef,
flushPendingEditorChange,
})
usePreparedNotePreload({
eventName: NOTE_CONTENT_CACHE_RESOLVED_EVENT,
editorMountedRef,
rawMode,
preparePath: preparePreloadedPath,
})
return { handleEditorChange, flushPendingEditorChange, editorMountedRef }
return { handleEditorChange: handleForegroundEditorChange, flushPendingEditorChange, editorMountedRef }
}

View File

@@ -499,7 +499,7 @@ describe('useNoteActions hook', () => {
})
describe('note open is read-only', () => {
it('does not sync title or reload entry when opening or freshness-validating a note', async () => {
it('does not sync title or reload entry when reopening an identity-matched cached note', async () => {
vi.mocked(isTauri).mockReturnValue(true)
const entry = makeEntry({ path: '/test/vault/qa-test.md', filename: 'qa-test.md', title: 'Qa Test' })
vi.mocked(invoke).mockImplementation(async (command) => {
@@ -516,10 +516,9 @@ describe('useNoteActions hook', () => {
const desyncedEntry = { ...entry, title: 'Wrong Title Desynced' }
await act(async () => { await result.current.handleSelectNote(desyncedEntry) })
expect(vi.mocked(invoke)).toHaveBeenCalledTimes(callCountAfterFirstOpen + 1)
expect(vi.mocked(invoke)).toHaveBeenCalledTimes(callCountAfterFirstOpen)
expect(vi.mocked(invoke).mock.calls).toEqual([
['get_note_content', { path: '/test/vault/qa-test.md' }],
['validate_note_content', { path: '/test/vault/qa-test.md', content: '# Qa Test\n' }],
])
expect(result.current.tabs[0].entry.title).toBe('Qa Test')
})

View File

@@ -470,7 +470,7 @@ async function createNoteImmediate(deps: ImmediateCreateDeps, type?: string): Pr
const didPersist = await persistImmediateEntry(deps, entry, content)
if (!didPersist) return false
cacheNoteContent(entry.path, content)
cacheNoteContent(entry.path, content, entry)
deps.openTabWithContent(entry, content)
addEntryWithMock(entry, content, deps.addEntry)
signalFocusEditor({ path: entry.path, selectTitle: true })

View File

@@ -1,85 +0,0 @@
import { useEffect, type MutableRefObject } from 'react'
const PREPARED_NOTE_PRELOAD_DELAY_MS = 75
interface PreparedNotePreloadOptions {
eventName: string
editorMountedRef: MutableRefObject<boolean>
rawMode?: boolean
preparePath: (path: string) => Promise<void> | void
}
interface PreloadQueueState {
timer: number | null
cancelled: boolean
paths: string[]
}
interface PreloadRunnerOptions extends Omit<PreparedNotePreloadOptions, 'eventName'> {
state: PreloadQueueState
}
function canSchedulePreload(state: PreloadQueueState): boolean {
return state.timer === null && state.paths.length > 0
}
function canRunPreload({ state, rawMode, editorMountedRef }: PreloadRunnerOptions): boolean {
return !state.cancelled && !rawMode && editorMountedRef.current
}
function schedulePreparedPreload(options: PreloadRunnerOptions): void {
const { state } = options
if (!canSchedulePreload(state)) return
state.timer = window.setTimeout(() => {
state.timer = null
if (!canRunPreload(options)) return
const nextPath = state.paths.shift()
if (!nextPath) return
Promise.resolve(options.preparePath(nextPath)).finally(() => {
schedulePreparedPreload(options)
})
}, PREPARED_NOTE_PRELOAD_DELAY_MS)
}
function enqueuePreloadPath(state: PreloadQueueState, path: string): void {
if (state.paths.includes(path)) return
state.paths.push(path)
}
function resolvedContentPath(event: Event): string | null {
return (event as CustomEvent<{ path?: string }>).detail?.path ?? null
}
function cancelPreloadQueue(state: PreloadQueueState): void {
state.cancelled = true
if (state.timer !== null) window.clearTimeout(state.timer)
}
export function usePreparedNotePreload({
eventName,
editorMountedRef,
rawMode,
preparePath,
}: PreparedNotePreloadOptions) {
useEffect(() => {
if (typeof window === 'undefined') return
const state: PreloadQueueState = { timer: null, cancelled: false, paths: [] }
const runner = { state, editorMountedRef, rawMode, preparePath }
const handleResolvedContent = (event: Event) => {
const path = resolvedContentPath(event)
if (!path) return
enqueuePreloadPath(state, path)
schedulePreparedPreload(runner)
}
window.addEventListener(eventName, handleResolvedContent)
return () => {
cancelPreloadQueue(state)
window.removeEventListener(eventName, handleResolvedContent)
}
}, [editorMountedRef, eventName, preparePath, rawMode])
}

View File

@@ -56,9 +56,9 @@ async function replaceActiveNote(result: HookState, overrides: Partial<VaultEntr
})
}
async function prefetchResolvedContent(path: string, content: string) {
async function prefetchResolvedContent(path: string, content: string, entry?: VaultEntry) {
mockNoteContent({ [path]: content })
prefetchNoteContent(path)
prefetchNoteContent(entry ?? path)
await vi.waitFor(() => expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1))
return mockInvoke
}
@@ -354,6 +354,24 @@ describe('useTabManagement (single-note model)', () => {
await replaceActiveNote(result, { path: '/vault/a.md' })
expectSingleActiveTab(result, '/vault/a.md')
})
it('validates cached content before replacing with a different active note', async () => {
cacheNoteContent('/vault/b.md', '# Stale cached B')
vi.mocked(mockInvoke).mockImplementation((cmd: string) => {
if (cmd === 'validate_note_content') return Promise.resolve(false)
return Promise.resolve('# Fresh disk B')
})
const { result } = renderHook(() => useTabManagement())
await selectNote(result, { path: '/vault/a.md', title: 'A' })
await replaceActiveNote(result, { path: '/vault/b.md', title: 'B' })
expect(result.current.tabs[0].content).toBe('# Fresh disk B')
expect(vi.mocked(mockInvoke)).toHaveBeenCalledWith('validate_note_content', {
path: '/vault/b.md',
content: '# Stale cached B',
})
})
})
describe('openTabWithContent', () => {
@@ -420,6 +438,21 @@ describe('useTabManagement (single-note model)', () => {
})
})
it('uses identity-matched prefetched content without re-reading the file', async () => {
const entry = makeEntry({
path: '/vault/note/pre.md',
modifiedAt: 1700000001,
fileSize: 19,
})
const mockInvoke = await prefetchResolvedContent(entry.path, '# Prefetched content', entry)
const { result } = renderHook(() => useTabManagement())
await selectNote(result, entry)
expect(result.current.tabs[0].content).toBe('# Prefetched content')
expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1)
})
it('does not paint cached content until freshness validation passes', async () => {
const freshness = createDeferred<boolean>()
cacheNoteContent('/vault/note/stale.md', '# Stale cached content')
@@ -611,7 +644,7 @@ describe('useTabManagement (single-note model)', () => {
expect(result.current.tabs[0].entry.path).toBe('/vault/a.md')
expect(result.current.tabs[0].content).toBe('# A content')
expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(3)
expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(2)
})
it('refreshes an already-open clean note when cached content is stale on disk', async () => {
@@ -621,7 +654,12 @@ describe('useTabManagement (single-note model)', () => {
await selectNote(result, { path: '/vault/a.md', title: 'A' })
mockNoteContent({ '/vault/a.md': '# External edit' })
await selectNote(result, { path: '/vault/a.md', title: 'A' })
await selectNote(result, {
path: '/vault/a.md',
title: 'A',
modifiedAt: 1700000001,
fileSize: 15,
})
expect(result.current.tabs[0].entry.path).toBe('/vault/a.md')
expect(result.current.tabs[0].content).toBe('# External edit')

View File

@@ -1,6 +1,4 @@
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,
@@ -8,216 +6,44 @@ import {
finishNoteOpenTrace,
markNoteOpenTrace,
} from '../utils/noteOpenPerformance'
import { getNoteWindowParams, isNoteWindow } from '../utils/windowMode'
import {
cacheNoteContent as cacheNoteContentInMemory,
clearNoteContentCache,
getCachedNoteContentEntry,
hasResolvedCachedContent,
isNoActiveVaultSelectedError,
isUnreadableNoteContentError,
loadContentForOpen,
NOTE_CONTENT_CACHE_LIMIT,
NOTE_CONTENT_CACHE_MAX_BYTES,
NOTE_CONTENT_ENTRY_MAX_BYTES,
prefetchNoteContent as prefetchNoteContentInMemory,
} from './noteContentCache'
import { clearParsedNoteBlockCache } from './editorParsedBlockCache'
interface Tab {
entry: VaultEntry
content: string
}
type NotePath = VaultEntry['path']
// --- Content prefetch cache ---
// Stores in-flight or recently loaded note content promises, keyed by path.
// Cleared on vault reload to prevent stale content after external edits.
// Latency profile: deduplicates rapid note switches and keeps revisits instant.
interface NoteContentCacheEntry {
path: NotePath
promise: Promise<string>
value: string | null
byteSize: number
export {
NOTE_CONTENT_CACHE_LIMIT,
NOTE_CONTENT_CACHE_MAX_BYTES,
NOTE_CONTENT_ENTRY_MAX_BYTES,
}
const prefetchCache = new Map<string, NoteContentCacheEntry>()
const contentSizeEncoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null
export const NOTE_CONTENT_CACHE_RESOLVED_EVENT = 'laputa:note-content-cache-resolved'
export const NOTE_CONTENT_CACHE_LIMIT = 48
export const NOTE_CONTENT_ENTRY_MAX_BYTES = 256 * 1024
export const NOTE_CONTENT_CACHE_MAX_BYTES = 1024 * 1024
function measureNoteContentBytes(content: string): number {
return contentSizeEncoder ? contentSizeEncoder.encode(content).byteLength : content.length
export function prefetchNoteContent(target: string | VaultEntry): void {
prefetchNoteContentInMemory(target)
}
function getRetainedPrefetchCacheBytes(): number {
let totalBytes = 0
for (const entry of prefetchCache.values()) {
totalBytes += entry.byteSize
}
return totalBytes
export function cacheNoteContent(path: string, content: string, entry?: VaultEntry): void {
cacheNoteContentInMemory(path, content, entry)
}
function dropOldestPrefetchEntry(): void {
const oldestPath = prefetchCache.keys().next().value
if (!oldestPath) return
prefetchCache.delete(oldestPath)
}
function trimPrefetchCache(): void {
while (
prefetchCache.size > NOTE_CONTENT_CACHE_LIMIT
|| getRetainedPrefetchCacheBytes() > NOTE_CONTENT_CACHE_MAX_BYTES
) {
if (prefetchCache.size === 0) return
dropOldestPrefetchEntry()
}
}
function rememberNoteContent(entry: NoteContentCacheEntry): NoteContentCacheEntry {
const { path } = entry
if (prefetchCache.has(path)) prefetchCache.delete(path)
prefetchCache.set(path, entry)
trimPrefetchCache()
return entry
}
function retainResolvedNoteContent(entry: NoteContentCacheEntry, content: string): void {
const byteSize = measureNoteContentBytes(content)
if (byteSize > NOTE_CONTENT_ENTRY_MAX_BYTES) {
prefetchCache.delete(entry.path)
return
}
entry.value = content
entry.byteSize = byteSize
rememberNoteContent(entry)
dispatchResolvedNoteContent(entry.path)
}
function getNoteContentCommandPayload(path: string): { path: string; vaultPath?: string } {
if (!isNoteWindow()) {
return { path }
}
const noteWindowParams = getNoteWindowParams()
return noteWindowParams
? { path, vaultPath: noteWindowParams.vaultPath }
: { path }
}
function getValidateNoteContentCommandPayload(path: string, content: string): { path: string; content: string; vaultPath?: string } {
return { ...getNoteContentCommandPayload(path), content }
}
function dispatchResolvedNoteContent(path: string): void {
if (typeof window === 'undefined') return
window.dispatchEvent(new CustomEvent(NOTE_CONTENT_CACHE_RESOLVED_EVENT, {
detail: { path },
}))
}
function requestNoteContent({ path }: Pick<NoteContentCacheEntry, 'path'>): NoteContentCacheEntry {
const cacheEntry: NoteContentCacheEntry = {
path,
promise: Promise.resolve(''),
value: null,
byteSize: 0,
}
const commandPayload = getNoteContentCommandPayload(path)
const promise = (isTauri()
? invoke<string>('get_note_content', commandPayload)
: mockInvoke<string>('get_note_content', commandPayload)
)
.then((content) => {
retainResolvedNoteContent(cacheEntry, content)
return content
})
.catch((err) => {
prefetchCache.delete(path)
throw err
})
cacheEntry.promise = promise
return rememberNoteContent(cacheEntry)
}
/** Prefetch a note's content into the in-memory cache.
* Safe to call multiple times — deduplicates concurrent requests for the same path.
* Cache is short-lived: cleared on vault reload via clearPrefetchCache(). */
export function prefetchNoteContent(path: string): void {
if (prefetchCache.has(path)) return
void requestNoteContent({ path }).promise.catch((error) => {
if (isNoActiveVaultSelectedError(error) || isUnreadableNoteContentError(error)) return
console.warn('Failed to prefetch note content:', error)
})
}
export function cacheNoteContent(path: string, content: string): void {
const byteSize = measureNoteContentBytes(content)
if (byteSize > NOTE_CONTENT_ENTRY_MAX_BYTES) {
prefetchCache.delete(path)
return
}
rememberNoteContent({
path,
promise: Promise.resolve(content),
value: content,
byteSize,
})
dispatchResolvedNoteContent(path)
}
/** Clear the prefetch cache. Call on vault reload to prevent stale content. */
/** Clear note-open caches. Call on vault reload to prevent stale content. */
export function clearPrefetchCache(): void {
prefetchCache.clear()
}
export function getResolvedCachedNoteContent(path: string): { path: string; content: string } | null {
const entry = prefetchCache.get(path)
if (!entry || entry.value === null) return null
return { path: entry.path, content: entry.value }
}
function hasResolvedCachedContent(entry: NoteContentCacheEntry | null): entry is NoteContentCacheEntry & { value: string } {
if (!entry) return false
return entry.value !== null
}
function getCachedNoteContentEntry(path: string): NoteContentCacheEntry | null {
return prefetchCache.get(path) ?? null
}
async function validateCachedNoteContent(entry: NoteContentCacheEntry): Promise<boolean> {
if (entry.value === null) return false
const payload = getValidateNoteContentCommandPayload(entry.path, entry.value)
return isTauri()
? invoke<boolean>('validate_note_content', payload)
: mockInvoke<boolean>('validate_note_content', payload)
}
async function loadNoteContent(path: string, forceFresh = false): Promise<string> {
if (forceFresh) return requestNoteContent({ path }).promise
return prefetchCache.get(path)?.promise ?? requestNoteContent({ path }).promise
}
async function loadCachedContentIfFresh(entry: NoteContentCacheEntry): Promise<string | null> {
if (entry.value === null) return null
markNoteOpenTrace(entry.path, 'freshnessCheckStart')
const isFresh = await validateCachedNoteContent(entry)
markNoteOpenTrace(entry.path, 'freshnessCheckEnd')
if (isFresh) {
rememberNoteContent(entry)
return entry.value
}
prefetchCache.delete(entry.path)
return null
}
async function loadContentForOpen(options: {
path: string
forceReload: boolean
cachedEntry: NoteContentCacheEntry | null
}): Promise<string> {
const { path, forceReload, cachedEntry } = options
if (!forceReload && hasResolvedCachedContent(cachedEntry)) {
const cachedContent = await loadCachedContentIfFresh(cachedEntry)
if (cachedContent !== null) return cachedContent
}
return loadNoteContent(path, forceReload || hasResolvedCachedContent(cachedEntry))
clearNoteContentCache()
clearParsedNoteBlockCache()
}
export type { Tab }
@@ -354,24 +180,6 @@ function isMissingNotePathError(error: unknown): boolean {
return /does not exist|not found|enoent/i.test(message)
}
function isNoActiveVaultSelectedError(error: unknown): boolean {
const message = error instanceof Error
? error.message
: typeof error === 'string'
? error
: String(error)
return /no active vault selected/i.test(message)
}
function isUnreadableNoteContentError(error: unknown): boolean {
const message = error instanceof Error
? error.message
: typeof error === 'string'
? error
: String(error)
return /not valid utf-8 text|invalid utf-8|stream did not contain valid utf-8/i.test(message)
}
function shouldApplyLoadedEntry(options: {
seq: number
navSeqRef: React.MutableRefObject<number>
@@ -576,7 +384,7 @@ async function loadTextEntry(options: Required<Pick<NavigateToEntryOptions, 'for
try {
markNoteOpenTrace(entry.path, 'contentLoadStart')
const content = await loadContentForOpen({
path: entry.path,
entry,
forceReload,
cachedEntry,
})
@@ -693,6 +501,7 @@ export function useTabManagement(options: TabManagementOptions = {}) {
const openTabWithContent = useCallback((entry: VaultEntry, content: string) => {
requestedActiveTabPathRef.current = entry.path
void executeNavigationWithBoundary(entry.path, () => {
cacheNoteContent(entry.path, content, entry)
setSingleTab(tabsRef, setTabs, { entry, content })
syncActiveTabPath(activeTabPathRef, setActiveTabPath, entry.path)
}).then((navigated) => {
@@ -702,12 +511,13 @@ export function useTabManagement(options: TabManagementOptions = {}) {
const handleReplaceActiveTab = useCallback(async (entry: VaultEntry) => {
requestedActiveTabPathRef.current = entry.path
if (!pathsMatch(entry.path, activeTabPathRef.current)) {
const replacingDifferentEntry = !pathsMatch(entry.path, activeTabPathRef.current)
if (replacingDifferentEntry) {
beginNoteOpenTrace(entry.path, 'replace-active-tab')
}
const navigated = await executeNavigationWithBoundary(entry.path, () => navigateToEntry({
entry,
forceReload: true,
forceReload: !replacingDifferentEntry,
navSeqRef,
tabsRef,
activeTabPathRef,

View File

@@ -191,6 +191,7 @@ describe('useVaultLoader extra', () => {
const { result } = renderHook(() => useVaultLoader('/vault'))
await waitForEntries(result)
vi.mocked(clearPrefetchCache).mockClear()
backendInvokeFn.mockImplementation((command: string) => {
if (command === 'reload_vault') {

View File

@@ -191,6 +191,7 @@ function useInitialVaultLoad({
}) {
useEffect(() => {
const path = vaultPath
clearPrefetchCache()
resetVaultState({
clearNewPaths: tracker.clear,
clearUnsaved: unsaved.clearAll,