import { useCallback, useEffect, useRef, useState } from 'react' import type { VaultEntry } from '../types' import { beginNoteOpenTrace, failNoteOpenTrace, finishNoteOpenTrace, markNoteOpenTrace, } from '../utils/noteOpenPerformance' import { cacheNoteContent as cacheNoteContentInMemory, clearNoteContentCache, getCachedNoteContentEntry, hasResolvedCachedContent, isNoActiveVaultSelectedError, isUnreadableNoteContentError, loadContentForOpen, NOTE_CONTENT_CACHE_LIMIT, NOTE_CONTENT_CACHE_MAX_BYTES, NOTE_CONTENT_ENTRY_MAX_BYTES, NOTE_CONTENT_PREFETCH_CONCURRENCY, prefetchNoteContent as prefetchNoteContentInMemory, type NoteContentRequestOptions, } from './noteContentCache' import { clearParsedNoteBlockCache } from './editorParsedBlockCache' import { notePathsMatch } from '../utils/notePathIdentity' import { normalizeVaultEntry } from '../utils/vaultMetadataNormalization' interface Tab { entry: VaultEntry content: string } export { NOTE_CONTENT_CACHE_LIMIT, NOTE_CONTENT_CACHE_MAX_BYTES, NOTE_CONTENT_ENTRY_MAX_BYTES, NOTE_CONTENT_PREFETCH_CONCURRENCY, } export function prefetchNoteContent(target: string | VaultEntry, options?: NoteContentRequestOptions): void { prefetchNoteContentInMemory(target, options) } export function cacheNoteContent( path: string, content: string, entry?: VaultEntry, options?: NoteContentRequestOptions, ): void { cacheNoteContentInMemory(path, content, entry, options) } /** Clear note-open caches. Call on vault reload to prevent stale content. */ export function clearPrefetchCache(): void { clearNoteContentCache() clearParsedNoteBlockCache() } export type { Tab } interface TabManagementOptions { beforeNavigate?: (fromPath: string, toPath: string) => Promise hasUnsavedChanges?: (path: string) => boolean onMissingActiveVault?: (entry: VaultEntry, error: unknown) => void | Promise onMissingNotePath?: (entry: VaultEntry, error: unknown) => void | Promise onUnreadableNoteContent?: (entry: VaultEntry, error: unknown) => void | Promise } interface NavigateToEntryOptions { entry: VaultEntry sourceEntry?: VaultEntry forceReload?: boolean navSeqRef: React.MutableRefObject tabsRef: React.MutableRefObject activeTabPathRef: React.MutableRefObject setTabs: React.Dispatch> setActiveTabPath: React.Dispatch> hasUnsavedChanges?: (path: string) => boolean onMissingActiveVault?: (entry: VaultEntry, error: unknown) => void | Promise onMissingNotePath?: (entry: VaultEntry, error: unknown) => void | Promise onUnreadableNoteContent?: (entry: VaultEntry, error: unknown) => void | Promise } function syncActiveTabPath( activeTabPathRef: React.MutableRefObject, setActiveTabPath: React.Dispatch>, path: string | null, ) { activeTabPathRef.current = path setActiveTabPath(path) } function resetRequestedPathIfStillPending( requestedActiveTabPathRef: React.MutableRefObject, activeTabPathRef: React.MutableRefObject, pendingPath: string, ) { if (requestedActiveTabPathRef.current === pendingPath) { requestedActiveTabPathRef.current = activeTabPathRef.current } } function setSingleTab( tabsRef: React.MutableRefObject, setTabs: React.Dispatch>, nextTab: Tab, ) { tabsRef.current = [nextTab] setTabs([nextTab]) } function clearTabs( tabsRef: React.MutableRefObject, setTabs: React.Dispatch>, ) { tabsRef.current = [] setTabs([]) } function normalizeOpenEntry(entry: VaultEntry): VaultEntry | null { const path = typeof entry.path === 'string' ? entry.path.trim() : '' if (!path) return null return normalizeVaultEntry({ ...entry, path }) } function callbackEntryForLoadFailure(entry: VaultEntry, sourceEntry?: VaultEntry): VaultEntry { return sourceEntry ? { ...sourceEntry, path: entry.path } : entry } function isAlreadyViewingPath( tabsRef: React.MutableRefObject, activeTabPathRef: React.MutableRefObject, path: string, ) { return notePathsMatch(activeTabPathRef.current, path) || tabsRef.current.some((tab) => notePathsMatch(tab.entry.path, path)) } function startEntryNavigation(options: { entry: VaultEntry navSeqRef: React.MutableRefObject activeTabPathRef: React.MutableRefObject setActiveTabPath: React.Dispatch> }) { const { entry, navSeqRef, activeTabPathRef, setActiveTabPath, } = options const seq = ++navSeqRef.current const cachedEntry = getCachedNoteContentEntry(entry.path) syncActiveTabPath(activeTabPathRef, setActiveTabPath, entry.path) if (hasResolvedCachedContent(cachedEntry)) { markNoteOpenTrace(entry.path, 'cacheReady') } return { seq, cachedEntry } } function openBinaryEntry(options: { entry: VaultEntry navSeqRef: React.MutableRefObject tabsRef: React.MutableRefObject activeTabPathRef: React.MutableRefObject setTabs: React.Dispatch> setActiveTabPath: React.Dispatch> }) { const { entry, navSeqRef, tabsRef, activeTabPathRef, setTabs, setActiveTabPath, } = options navSeqRef.current += 1 syncActiveTabPath(activeTabPathRef, setActiveTabPath, entry.path) setSingleTab(tabsRef, setTabs, { entry, content: '' }) finishNoteOpenTrace(entry.path) } function isMissingNotePathError(error: unknown): boolean { const message = error instanceof Error ? error.message : typeof error === 'string' ? error : String(error) return /does not exist|not found|enoent/i.test(message) } function shouldApplyLoadedEntry(options: { seq: number navSeqRef: React.MutableRefObject content: string forceReload: boolean activeTabPathRef: React.MutableRefObject tabsRef: React.MutableRefObject path: string }) { const { seq, navSeqRef, content, forceReload, activeTabPathRef, tabsRef, path, } = options if (navSeqRef.current !== seq) return false if (forceReload) return true if (!notePathsMatch(activeTabPathRef.current, path)) return true const openTab = tabsRef.current.find((tab) => notePathsMatch(tab.entry.path, path)) return !openTab || openTab.content !== content } type EntryLoadFailureKind = | 'missing-active-vault' | 'missing-path' | 'unreadable-content' | 'load-failed' type RecoverableEntryLoadFailureKind = Exclude function getEntryLoadFailureKind(error: unknown): EntryLoadFailureKind { if (isNoActiveVaultSelectedError(error)) return 'missing-active-vault' if (isMissingNotePathError(error)) return 'missing-path' if (isUnreadableNoteContentError(error)) return 'unreadable-content' return 'load-failed' } function resetFailedEntrySelection(options: { tabsRef: React.MutableRefObject activeTabPathRef: React.MutableRefObject setTabs: React.Dispatch> setActiveTabPath: React.Dispatch> }) { const { tabsRef, activeTabPathRef, setTabs, setActiveTabPath } = options clearTabs(tabsRef, setTabs) syncActiveTabPath(activeTabPathRef, setActiveTabPath, null) } function runEntryFailureCallback(options: { callback?: (entry: VaultEntry, error: unknown) => void | Promise entry: VaultEntry error: unknown warning: string }) { const { callback, entry, error, warning } = options Promise.resolve(callback?.(entry, error)).catch((callbackError) => { console.warn(warning, callbackError) }) } function handleRecoverableEntryLoadFailure(options: { kind: RecoverableEntryLoadFailureKind entry: VaultEntry callbackEntry: VaultEntry tabsRef: React.MutableRefObject activeTabPathRef: React.MutableRefObject setTabs: React.Dispatch> setActiveTabPath: React.Dispatch> error: unknown onMissingActiveVault?: (entry: VaultEntry, error: unknown) => void | Promise onMissingNotePath?: (entry: VaultEntry, error: unknown) => void | Promise onUnreadableNoteContent?: (entry: VaultEntry, error: unknown) => void | Promise }) { const { kind, entry, callbackEntry, tabsRef, activeTabPathRef, setTabs, setActiveTabPath, error, onMissingActiveVault, onMissingNotePath, onUnreadableNoteContent, } = options if (kind === 'missing-active-vault') { clearPrefetchCache() } resetFailedEntrySelection({ tabsRef, activeTabPathRef, setTabs, setActiveTabPath, }) failNoteOpenTrace(entry.path, kind) if (kind === 'missing-active-vault') { runEntryFailureCallback({ callback: onMissingActiveVault, entry: callbackEntry, error, warning: 'Failed to handle missing active vault:', }) return } if (kind === 'missing-path') { runEntryFailureCallback({ callback: onMissingNotePath, entry: callbackEntry, error, warning: 'Failed to handle missing note path:', }) return } if (kind === 'unreadable-content') { runEntryFailureCallback({ callback: onUnreadableNoteContent, entry: callbackEntry, error, warning: 'Failed to handle unreadable note content:', }) } } function handleEntryLoadFailure(options: { entry: VaultEntry callbackEntry: VaultEntry seq: number navSeqRef: React.MutableRefObject tabsRef: React.MutableRefObject activeTabPathRef: React.MutableRefObject setTabs: React.Dispatch> setActiveTabPath: React.Dispatch> error: unknown onMissingActiveVault?: (entry: VaultEntry, error: unknown) => void | Promise onMissingNotePath?: (entry: VaultEntry, error: unknown) => void | Promise onUnreadableNoteContent?: (entry: VaultEntry, error: unknown) => void | Promise }) { const { entry, callbackEntry, seq, navSeqRef, tabsRef, activeTabPathRef, setTabs, setActiveTabPath, error, onMissingActiveVault, onMissingNotePath, onUnreadableNoteContent, } = options console.warn('Failed to load note content:', error) if (navSeqRef.current !== seq) return const failureKind = getEntryLoadFailureKind(error) if (failureKind !== 'load-failed') { handleRecoverableEntryLoadFailure({ kind: failureKind, entry, callbackEntry, tabsRef, activeTabPathRef, setTabs, setActiveTabPath, error, onMissingActiveVault, onMissingNotePath, onUnreadableNoteContent, }) return } resetFailedEntrySelection({ tabsRef, activeTabPathRef, setTabs, setActiveTabPath, }) failNoteOpenTrace(entry.path, 'load-failed') } function reopenAlreadyViewingEntry({ entry, tabsRef, activeTabPathRef, setActiveTabPath, hasUnsavedChanges, }: Pick): boolean { if (!isAlreadyViewingPath(tabsRef, activeTabPathRef, entry.path)) return false if (!hasUnsavedChanges?.(entry.path)) return false syncActiveTabPath(activeTabPathRef, setActiveTabPath, entry.path) finishNoteOpenTrace(entry.path) return true } async function loadTextEntry(options: Required> & NavigateToEntryOptions) { const { entry, sourceEntry, forceReload, navSeqRef, tabsRef, activeTabPathRef, setTabs, setActiveTabPath, onMissingActiveVault, onMissingNotePath, onUnreadableNoteContent, } = options const { seq, cachedEntry } = startEntryNavigation({ entry, navSeqRef, activeTabPathRef, setActiveTabPath, }) try { markNoteOpenTrace(entry.path, 'contentLoadStart') const content = await loadContentForOpen({ entry, forceReload, cachedEntry, }) markNoteOpenTrace(entry.path, 'contentLoadEnd') if (!shouldApplyLoadedEntry({ seq, navSeqRef, content, forceReload, activeTabPathRef, tabsRef, path: entry.path, })) return setSingleTab(tabsRef, setTabs, { entry, content }) } catch (err) { handleEntryLoadFailure({ entry, callbackEntry: callbackEntryForLoadFailure(entry, sourceEntry), seq, navSeqRef, tabsRef, activeTabPathRef, setTabs, setActiveTabPath, error: err, onMissingActiveVault, onMissingNotePath, onUnreadableNoteContent, }) } } async function navigateToEntry(options: NavigateToEntryOptions) { const forceReload = options.forceReload ?? false if (options.entry.fileKind === 'binary') { openBinaryEntry(options) return } if (!forceReload && reopenAlreadyViewingEntry(options)) return await loadTextEntry({ ...options, forceReload }) } export function useTabManagement(options: TabManagementOptions = {}) { // Single-note model: tabs has 0 or 1 elements. const [tabs, setTabs] = useState([]) const [activeTabPath, setActiveTabPath] = useState(null) const activeTabPathRef = useRef(activeTabPath) const requestedActiveTabPathRef = useRef(activeTabPath) useEffect(() => { activeTabPathRef.current = activeTabPath }) const tabsRef = useRef(tabs) useEffect(() => { tabsRef.current = tabs }) // Sequence counter for rapid-switch safety: only the latest navigation wins. const navSeqRef = useRef(0) const beforeNavigateSeqRef = useRef(0) const beforeNavigate = options.beforeNavigate const hasUnsavedChanges = options.hasUnsavedChanges const onMissingActiveVault = options.onMissingActiveVault const onMissingNotePath = options.onMissingNotePath const onUnreadableNoteContent = options.onUnreadableNoteContent const executeNavigationWithBoundary = useCallback(async ( targetPath: string, navigate: () => void | Promise, ) => { const seq = ++beforeNavigateSeqRef.current const currentPath = activeTabPathRef.current if (beforeNavigate && currentPath && !notePathsMatch(currentPath, targetPath)) { try { markNoteOpenTrace(targetPath, 'beforeNavigateStart') await beforeNavigate(currentPath, targetPath) markNoteOpenTrace(targetPath, 'beforeNavigateEnd') } catch (err) { console.warn('Failed to persist note before navigation:', err) failNoteOpenTrace(targetPath, 'before-navigate-failed') return false } if (beforeNavigateSeqRef.current !== seq) return false } await navigate() return true }, [beforeNavigate]) /** Open a note — replaces the current note (single-note model). */ const handleSelectNote = useCallback(async (entry: VaultEntry) => { const openEntry = normalizeOpenEntry(entry) if (!openEntry) return requestedActiveTabPathRef.current = openEntry.path const alreadyViewingDirtyEntry = notePathsMatch(openEntry.path, activeTabPathRef.current) && !!hasUnsavedChanges?.(openEntry.path) if (!alreadyViewingDirtyEntry) { beginNoteOpenTrace(openEntry.path, 'select-note') } const navigated = await executeNavigationWithBoundary(openEntry.path, () => navigateToEntry({ entry: openEntry, sourceEntry: entry, navSeqRef, tabsRef, activeTabPathRef, setTabs, setActiveTabPath, hasUnsavedChanges, onMissingActiveVault, onMissingNotePath, onUnreadableNoteContent, })) if (!navigated) { resetRequestedPathIfStillPending(requestedActiveTabPathRef, activeTabPathRef, openEntry.path) } }, [executeNavigationWithBoundary, hasUnsavedChanges, onMissingActiveVault, onMissingNotePath, onUnreadableNoteContent]) const handleSwitchTab = useCallback((path: string) => { requestedActiveTabPathRef.current = path syncActiveTabPath(activeTabPathRef, setActiveTabPath, path) }, []) /** Open a tab with known content — no IPC round-trip. Used for newly created notes. */ const openTabWithContent = useCallback((entry: VaultEntry, content: string) => { const openEntry = normalizeOpenEntry(entry) if (!openEntry) return requestedActiveTabPathRef.current = openEntry.path void executeNavigationWithBoundary(openEntry.path, () => { cacheNoteContent(openEntry.path, content, openEntry) setSingleTab(tabsRef, setTabs, { entry: openEntry, content }) syncActiveTabPath(activeTabPathRef, setActiveTabPath, openEntry.path) }).then((navigated) => { if (!navigated) resetRequestedPathIfStillPending(requestedActiveTabPathRef, activeTabPathRef, openEntry.path) }) }, [executeNavigationWithBoundary]) const handleReplaceActiveTab = useCallback(async (entry: VaultEntry) => { const openEntry = normalizeOpenEntry(entry) if (!openEntry) return requestedActiveTabPathRef.current = openEntry.path const replacingDifferentEntry = !notePathsMatch(openEntry.path, activeTabPathRef.current) if (replacingDifferentEntry) { beginNoteOpenTrace(openEntry.path, 'replace-active-tab') } const navigated = await executeNavigationWithBoundary(openEntry.path, () => navigateToEntry({ entry: openEntry, sourceEntry: entry, forceReload: !replacingDifferentEntry, navSeqRef, tabsRef, activeTabPathRef, setTabs, setActiveTabPath, onMissingActiveVault, onMissingNotePath, onUnreadableNoteContent, })) if (!navigated) { resetRequestedPathIfStillPending(requestedActiveTabPathRef, activeTabPathRef, openEntry.path) } }, [executeNavigationWithBoundary, onMissingActiveVault, onMissingNotePath, onUnreadableNoteContent]) const closeAllTabs = useCallback(() => { navSeqRef.current += 1 beforeNavigateSeqRef.current += 1 tabsRef.current = [] setTabs([]) requestedActiveTabPathRef.current = null syncActiveTabPath(activeTabPathRef, setActiveTabPath, null) }, []) return { tabs, setTabs, activeTabPath, activeTabPathRef, requestedActiveTabPathRef, handleSelectNote, openTabWithContent, handleSwitchTab, handleReplaceActiveTab, closeAllTabs, } }