2026-05-01 23:14:36 +02:00
|
|
|
import { invoke } from '@tauri-apps/api/core'
|
|
|
|
|
import { isTauri, mockInvoke } from '../mock-tauri'
|
|
|
|
|
import type { VaultEntry } from '../types'
|
|
|
|
|
import { markNoteOpenTrace } from '../utils/noteOpenPerformance'
|
2026-05-02 17:40:58 +02:00
|
|
|
import { errorMessage, isActiveVaultUnavailableError } from '../utils/vaultErrors'
|
2026-05-01 23:14:36 +02:00
|
|
|
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
|
2026-05-05 19:11:17 +02:00
|
|
|
requestState?: NoteContentRequestState
|
|
|
|
|
startRequest?: () => void
|
|
|
|
|
cancelRequest?: () => void
|
2026-05-01 23:14:36 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface NoteContentResolvedEvent {
|
|
|
|
|
entry: VaultEntry | null
|
|
|
|
|
path: NotePath
|
|
|
|
|
content: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type NoteContentResolvedListener = (event: NoteContentResolvedEvent) => void
|
2026-05-05 19:11:17 +02:00
|
|
|
type NoteContentRequestState = 'queued' | 'running' | 'settled' | 'canceled'
|
|
|
|
|
type NoteContentRequestMode = 'foreground' | 'prefetch'
|
2026-05-01 23:14:36 +02:00
|
|
|
|
|
|
|
|
const prefetchCache = new Map<string, NoteContentCacheEntry>()
|
2026-05-05 19:11:17 +02:00
|
|
|
const prefetchQueue: NoteContentCacheEntry[] = []
|
2026-05-01 23:14:36 +02:00
|
|
|
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
|
2026-05-05 19:11:17 +02:00
|
|
|
export const NOTE_CONTENT_PREFETCH_CONCURRENCY = 4
|
|
|
|
|
const NOTE_CONTENT_REQUEST_CANCELED = 'Note content request canceled'
|
|
|
|
|
|
|
|
|
|
let activePrefetchRequests = 0
|
2026-05-01 23:14:36 +02:00
|
|
|
|
|
|
|
|
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
|
2026-05-05 19:11:17 +02:00
|
|
|
const entry = prefetchCache.get(oldestPath)
|
|
|
|
|
entry?.cancelRequest?.()
|
2026-05-01 23:14:36 +02:00
|
|
|
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
|
2026-05-05 19:11:17 +02:00
|
|
|
const existing = prefetchCache.get(path)
|
|
|
|
|
if (existing && existing !== entry) {
|
|
|
|
|
removeQueuedPrefetch(existing)
|
|
|
|
|
existing.cancelRequest?.()
|
|
|
|
|
prefetchCache.delete(path)
|
|
|
|
|
} else if (existing) {
|
|
|
|
|
prefetchCache.delete(path)
|
|
|
|
|
}
|
2026-05-01 23:14:36 +02:00
|
|
|
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 }
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-05 19:11:17 +02:00
|
|
|
function runGetNoteContentCommand(path: string): Promise<string> {
|
|
|
|
|
const commandPayload = getNoteContentCommandPayload(path)
|
|
|
|
|
return isTauri()
|
|
|
|
|
? invoke<string>('get_note_content', commandPayload)
|
|
|
|
|
: mockInvoke<string>('get_note_content', commandPayload)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-01 23:14:36 +02:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-05 19:11:17 +02:00
|
|
|
function markRequestSettled(entry: NoteContentCacheEntry, state: Extract<NoteContentRequestState, 'settled' | 'canceled'>): void {
|
|
|
|
|
entry.requestState = state
|
|
|
|
|
entry.startRequest = undefined
|
|
|
|
|
entry.cancelRequest = undefined
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createNoteContentRequest(target: string | VaultEntry): NoteContentCacheEntry {
|
2026-05-01 23:14:36 +02:00
|
|
|
const path = targetPath(target)
|
|
|
|
|
const sourceEntry = targetEntry(target)
|
|
|
|
|
const identity = targetIdentity(target)
|
|
|
|
|
const cacheEntry: NoteContentCacheEntry = {
|
|
|
|
|
path,
|
|
|
|
|
promise: Promise.resolve(''),
|
|
|
|
|
value: null,
|
|
|
|
|
byteSize: 0,
|
|
|
|
|
identity,
|
2026-05-05 19:11:17 +02:00
|
|
|
requestState: 'queued',
|
2026-05-01 23:14:36 +02:00
|
|
|
}
|
2026-05-05 19:11:17 +02:00
|
|
|
let started = false
|
|
|
|
|
let settled = false
|
|
|
|
|
let startRequest: () => void = () => {}
|
|
|
|
|
let cancelRequest: () => void = () => {}
|
|
|
|
|
const promise = new Promise<string>((resolve, reject) => {
|
|
|
|
|
startRequest = () => {
|
|
|
|
|
if (started || settled) return
|
|
|
|
|
started = true
|
|
|
|
|
cacheEntry.requestState = 'running'
|
|
|
|
|
runGetNoteContentCommand(path)
|
|
|
|
|
.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))
|
|
|
|
|
}
|
|
|
|
|
})
|
2026-05-01 23:14:36 +02:00
|
|
|
|
|
|
|
|
cacheEntry.promise = promise
|
2026-05-05 19:11:17 +02:00
|
|
|
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'): NoteContentCacheEntry {
|
|
|
|
|
const cacheEntry = rememberNoteContent(createNoteContentRequest(target))
|
|
|
|
|
if (mode === 'prefetch') {
|
|
|
|
|
enqueuePrefetchRequest(cacheEntry)
|
|
|
|
|
} else {
|
|
|
|
|
startNoteContentRequestNow(cacheEntry)
|
|
|
|
|
}
|
|
|
|
|
return cacheEntry
|
2026-05-01 23:14:36 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
2026-05-05 19:11:17 +02:00
|
|
|
void requestNoteContent(target, 'prefetch').promise.catch((error) => {
|
|
|
|
|
if (isCanceledNoteContentRequest(error) || isNoActiveVaultSelectedError(error) || isUnreadableNoteContentError(error)) return
|
2026-05-01 23:14:36 +02:00
|
|
|
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 {
|
2026-05-05 19:11:17 +02:00
|
|
|
for (const entry of prefetchQueue.splice(0)) entry.cancelRequest?.()
|
2026-05-01 23:14:36 +02:00
|
|
|
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)) {
|
2026-05-05 19:11:17 +02:00
|
|
|
startNoteContentRequestNow(existing)
|
2026-05-01 23:14:36 +02:00
|
|
|
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))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function isNoActiveVaultSelectedError(error: unknown): boolean {
|
2026-05-02 17:40:58 +02:00
|
|
|
return isActiveVaultUnavailableError(error)
|
2026-05-01 23:14:36 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-05 19:11:17 +02:00
|
|
|
export function isCanceledNoteContentRequest(error: unknown): boolean {
|
|
|
|
|
return errorMessage(error) === NOTE_CONTENT_REQUEST_CANCELED
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-01 23:14:36 +02:00
|
|
|
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))
|
|
|
|
|
}
|