feat: cache note content and parsed editor blocks
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
167
src-tauri/src/commands/memory.rs
Normal file
167
src-tauri/src/commands/memory.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
82
src/components/EditorMemoryProbe.tsx
Normal file
82
src/components/EditorMemoryProbe.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
74
src/components/HiddenEditorMemoryProbe.tsx
Normal file
74
src/components/HiddenEditorMemoryProbe.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 }),
|
||||
}
|
||||
|
||||
@@ -115,6 +115,6 @@ describe('NoteList keyboard activation', () => {
|
||||
|
||||
fireEvent.mouseEnter(noteRow!)
|
||||
|
||||
expect(prefetchSpy).toHaveBeenCalledWith(mockEntries[1].path)
|
||||
expect(prefetchSpy).toHaveBeenCalledWith(mockEntries[1])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
|
||||
82
src/components/editorMemoryProbeRuntime.ts
Normal file
82
src/components/editorMemoryProbeRuntime.ts
Normal 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())))
|
||||
}
|
||||
61
src/components/editorMemoryProbeTypes.ts
Normal file
61
src/components/editorMemoryProbeTypes.ts
Normal 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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
|
||||
131
src/components/useEditorMemoryProbeController.ts
Normal file
131
src/components/useEditorMemoryProbeController.ts
Normal 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 }
|
||||
}
|
||||
216
src/hooks/editorBlockResolution.ts
Normal file
216
src/hooks/editorBlockResolution.ts
Normal 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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
92
src/hooks/editorParsedBlockCache.ts
Normal file
92
src/hooks/editorParsedBlockCache.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
110
src/hooks/editorParsedBlockPreload.ts
Normal file
110
src/hooks/editorParsedBlockPreload.ts
Normal 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])
|
||||
}
|
||||
77
src/hooks/editorSwapToken.ts
Normal file
77
src/hooks/editorSwapToken.ts
Normal 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
|
||||
}
|
||||
288
src/hooks/noteContentCache.ts
Normal file
288
src/hooks/noteContentCache.ts
Normal 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))
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -191,6 +191,7 @@ function useInitialVaultLoad({
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const path = vaultPath
|
||||
clearPrefetchCache()
|
||||
resetVaultState({
|
||||
clearNewPaths: tracker.clear,
|
||||
clearUnsaved: unsaved.clearAll,
|
||||
|
||||
Reference in New Issue
Block a user