import { invoke } from '@tauri-apps/api/core' import { isTauri, mockInvoke } from '../mock-tauri' import type { VaultEntry } from '../types' import { workspacePathForEntry } from '../utils/workspaces' import { markNoteOpenTrace } from '../utils/noteOpenPerformance' import { errorMessage, isActiveVaultUnavailableError } from '../utils/vaultErrors' 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 value: string | null byteSize: number identity: NoteContentIdentity | null vaultPath?: string parsedBlockPreload: boolean parsedBlockPreloadNotified: boolean requestState?: NoteContentRequestState startRequest?: () => void cancelRequest?: () => void } export interface NoteContentResolvedEvent { entry: VaultEntry | null path: NotePath content: string parsedBlockPreload: boolean } export interface NoteContentRequestOptions { parsedBlockPreload?: boolean } type NoteContentResolvedListener = (event: NoteContentResolvedEvent) => void type NoteContentRequestState = 'queued' | 'running' | 'settled' | 'canceled' type NoteContentRequestMode = 'foreground' | 'prefetch' const prefetchCache = new Map() const prefetchQueue: NoteContentCacheEntry[] = [] const resolvedListeners = new Set() 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 const NOTE_CONTENT_PREFETCH_CONCURRENCY = 4 const NOTE_CONTENT_REQUEST_CANCELED = 'Note content request canceled' const NOTE_CONTENT_LOAD_RETRY_DELAYS_MS = [120, 320, 800] as const let activePrefetchRequests = 0 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 shouldRequestParsedBlockPreload(options?: NoteContentRequestOptions): boolean { return options?.parsedBlockPreload ?? true } 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 targetVaultPath(target: string | VaultEntry): string | undefined { const entry = targetEntry(target) return entry ? workspacePathForEntry(entry) : undefined } 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 const entry = prefetchCache.get(oldestPath) entry?.cancelRequest?.() 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 const existing = prefetchCache.get(path) if (existing && existing !== entry) { removeQueuedPrefetch(existing) existing.cancelRequest?.() prefetchCache.delete(path) } else if (existing) { 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) if (entry.parsedBlockPreload) entry.parsedBlockPreloadNotified = true emitNoteContentResolved({ entry: sourceEntry, path: entry.path, content, parsedBlockPreload: entry.parsedBlockPreload, }) } function getNoteContentCommandPayload(path: string, vaultPath?: string): { path: string; vaultPath?: string } { if (vaultPath) return { path, vaultPath } if (!isNoteWindow()) return { path } const noteWindowParams = getNoteWindowParams() return noteWindowParams ? { path, vaultPath: noteWindowParams.vaultPath } : { path } } function runGetNoteContentCommand(path: string, vaultPath?: string): Promise { const commandPayload = getNoteContentCommandPayload(path, vaultPath) return isTauri() ? invoke('get_note_content', commandPayload) : mockInvoke('get_note_content', commandPayload) } function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } function isMissingNoteContentError(error: unknown): boolean { return /does not exist|not found|enoent/i.test(errorMessage(error)) } function isRetryableNoteContentLoadError(error: unknown): boolean { return !isNoActiveVaultSelectedError(error) && !isMissingNoteContentError(error) && !isUnreadableNoteContentError(error) } async function runGetNoteContentCommandWithRetry(path: string, vaultPath?: string): Promise { let lastError: unknown for (let attempt = 0; attempt <= NOTE_CONTENT_LOAD_RETRY_DELAYS_MS.length; attempt += 1) { try { return await runGetNoteContentCommand(path, vaultPath) } catch (error) { lastError = error if (!isRetryableNoteContentLoadError(error) || attempt === NOTE_CONTENT_LOAD_RETRY_DELAYS_MS.length) { throw error } await delay(NOTE_CONTENT_LOAD_RETRY_DELAYS_MS[attempt]) } } throw lastError } function getValidateNoteContentCommandPayload(path: string, content: string, vaultPath?: string): { path: string; content: string; vaultPath?: string } { return { ...getNoteContentCommandPayload(path, vaultPath), content } } function shouldReuseExistingRequest( existing: NoteContentCacheEntry, identity: NoteContentIdentity | null, vaultPath?: string, ): boolean { if (existing.vaultPath !== vaultPath) return false if (!isCompleteIdentity(identity) || !isCompleteIdentity(existing.identity)) return true return sameIdentity(existing.identity, identity) } function markRequestSettled(entry: NoteContentCacheEntry, state: Extract): void { entry.requestState = state entry.startRequest = undefined entry.cancelRequest = undefined } function requestParsedBlockPreload(entry: NoteContentCacheEntry, sourceEntry: VaultEntry | null): void { entry.parsedBlockPreload = true if (entry.value === null || entry.parsedBlockPreloadNotified) return entry.parsedBlockPreloadNotified = true emitNoteContentResolved({ entry: sourceEntry, path: entry.path, content: entry.value, parsedBlockPreload: true, }) } function createNoteContentRequest(target: string | VaultEntry, options?: NoteContentRequestOptions): NoteContentCacheEntry { const path = targetPath(target) const sourceEntry = targetEntry(target) const vaultPath = targetVaultPath(target) const identity = targetIdentity(target) const parsedBlockPreload = shouldRequestParsedBlockPreload(options) const cacheEntry: NoteContentCacheEntry = { path, promise: Promise.resolve(''), value: null, byteSize: 0, identity, vaultPath, parsedBlockPreload, parsedBlockPreloadNotified: false, requestState: 'queued', } let started = false let settled = false let startRequest: () => void = () => {} let cancelRequest: () => void = () => {} const promise = new Promise((resolve, reject) => { startRequest = () => { if (started || settled) return started = true cacheEntry.requestState = 'running' runGetNoteContentCommandWithRetry(path, vaultPath) .then((content) => { settled = true markRequestSettled(cacheEntry, 'settled') retainResolvedNoteContent(cacheEntry, content, sourceEntry) resolve(content) }) .catch((err) => { settled = true markRequestSettled(cacheEntry, 'settled') if (prefetchCache.get(path) === cacheEntry) prefetchCache.delete(path) reject(err) }) } cancelRequest = () => { if (started || settled) return settled = true markRequestSettled(cacheEntry, 'canceled') reject(new Error(NOTE_CONTENT_REQUEST_CANCELED)) } }) cacheEntry.promise = promise cacheEntry.startRequest = startRequest cacheEntry.cancelRequest = cancelRequest return cacheEntry } function removeQueuedPrefetch(entry: NoteContentCacheEntry): void { const index = prefetchQueue.indexOf(entry) if (index >= 0) prefetchQueue.splice(index, 1) } function startNoteContentRequestNow(entry: NoteContentCacheEntry): void { removeQueuedPrefetch(entry) entry.startRequest?.() } function runQueuedPrefetches(): void { while (activePrefetchRequests < NOTE_CONTENT_PREFETCH_CONCURRENCY && prefetchQueue.length > 0) { const entry = prefetchQueue.shift() if (!entry || prefetchCache.get(entry.path) !== entry || entry.requestState !== 'queued') continue activePrefetchRequests += 1 void entry.promise .finally(() => { activePrefetchRequests = Math.max(0, activePrefetchRequests - 1) runQueuedPrefetches() }) .catch(() => {}) entry.startRequest?.() } } function enqueuePrefetchRequest(entry: NoteContentCacheEntry): void { prefetchQueue.push(entry) runQueuedPrefetches() } function requestNoteContent( target: string | VaultEntry, mode: NoteContentRequestMode = 'foreground', options?: NoteContentRequestOptions, ): NoteContentCacheEntry { const cacheEntry = rememberNoteContent(createNoteContentRequest(target, options)) if (mode === 'prefetch') { enqueuePrefetchRequest(cacheEntry) } else { startNoteContentRequestNow(cacheEntry) } return cacheEntry } export function prefetchNoteContent(target: string | VaultEntry, options?: NoteContentRequestOptions): void { const path = targetPath(target) const identity = targetIdentity(target) const vaultPath = targetVaultPath(target) const existing = prefetchCache.get(path) if (existing && shouldReuseExistingRequest(existing, identity, vaultPath)) { if (shouldRequestParsedBlockPreload(options)) requestParsedBlockPreload(existing, targetEntry(target)) return } void requestNoteContent(target, 'prefetch', options).promise.catch((error) => { if (isCanceledNoteContentRequest(error) || isNoActiveVaultSelectedError(error) || isUnreadableNoteContentError(error)) return console.warn('Failed to prefetch note content:', error) }) } export function cacheNoteContent( path: string, content: string, entry?: VaultEntry, options?: NoteContentRequestOptions, ): void { const byteSize = measureNoteContentBytes(content) if (byteSize > NOTE_CONTENT_ENTRY_MAX_BYTES) { prefetchCache.delete(path) return } const cacheEntry = rememberNoteContent({ path, promise: Promise.resolve(content), value: content, byteSize, identity: entry ? noteContentIdentity(entry) : null, vaultPath: entry ? workspacePathForEntry(entry) : undefined, parsedBlockPreload: shouldRequestParsedBlockPreload(options), parsedBlockPreloadNotified: false, }) if (cacheEntry.parsedBlockPreload) cacheEntry.parsedBlockPreloadNotified = true emitNoteContentResolved({ entry: entry ?? null, path, content, parsedBlockPreload: cacheEntry.parsedBlockPreload, }) } export function clearNoteContentCache(): void { for (const entry of prefetchQueue.splice(0)) entry.cancelRequest?.() 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 { if (entry.value === null) return false const payload = getValidateNoteContentCommandPayload(entry.path, entry.value, entry.vaultPath) return isTauri() ? invoke('validate_note_content', payload) : mockInvoke('validate_note_content', payload) } function matchesCachedContentVault(entry: VaultEntry, cachedEntry: NoteContentCacheEntry): boolean { return cachedEntry.vaultPath === targetVaultPath(entry) } function canTrustCachedContentIdentity(entry: VaultEntry, cachedEntry: NoteContentCacheEntry): boolean { return matchesCachedContentVault(entry, cachedEntry) && 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), targetVaultPath(target)) } async function loadNoteContent(target: VaultEntry, forceFresh = false): Promise { const existing = prefetchCache.get(target.path) if (canUseExistingContentRequest(target, existing, forceFresh)) { startNoteContentRequestNow(existing) return existing.promise } return requestNoteContent(target).promise } async function loadCachedContentIfFresh(entry: VaultEntry, cachedEntry: NoteContentCacheEntry): Promise { if (cachedEntry.value === null) return null if (!matchesCachedContentVault(entry, cachedEntry)) { prefetchCache.delete(entry.path) 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 { 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)) } export function isNoActiveVaultSelectedError(error: unknown): boolean { return isActiveVaultUnavailableError(error) } export function isCanceledNoteContentRequest(error: unknown): boolean { return errorMessage(error) === NOTE_CONTENT_REQUEST_CANCELED } 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)) }