feat: cache note content and parsed editor blocks
This commit is contained in:
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))
|
||||
}
|
||||
Reference in New Issue
Block a user