Files
tolaria/src/hooks/useTabManagement.ts

607 lines
18 KiB
TypeScript
Raw Normal View History

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'
2026-05-07 06:41:50 +02:00
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,
2026-04-24 21:53:47 +02:00
}
2026-04-19 11:29:04 +02:00
export function prefetchNoteContent(target: string | VaultEntry, options?: NoteContentRequestOptions): void {
prefetchNoteContentInMemory(target, options)
2026-04-24 21:53:47 +02:00
}
export function cacheNoteContent(
path: string,
content: string,
entry?: VaultEntry,
options?: NoteContentRequestOptions,
): void {
cacheNoteContentInMemory(path, content, entry, options)
2026-04-24 23:29:44 +02:00
}
/** Clear note-open caches. Call on vault reload to prevent stale content. */
export function clearPrefetchCache(): void {
clearNoteContentCache()
clearParsedNoteBlockCache()
2026-04-29 10:08:33 +02:00
}
export type { Tab }
interface TabManagementOptions {
beforeNavigate?: (fromPath: string, toPath: string) => Promise<void>
hasUnsavedChanges?: (path: string) => boolean
onMissingActiveVault?: (entry: VaultEntry, error: unknown) => void | Promise<void>
2026-04-22 20:18:26 +02:00
onMissingNotePath?: (entry: VaultEntry, error: unknown) => void | Promise<void>
onUnreadableNoteContent?: (entry: VaultEntry, error: unknown) => void | Promise<void>
}
2026-04-27 00:36:24 +02:00
interface NavigateToEntryOptions {
entry: VaultEntry
2026-05-07 06:41:50 +02:00
sourceEntry?: VaultEntry
2026-04-27 00:36:24 +02:00
forceReload?: boolean
navSeqRef: React.MutableRefObject<number>
tabsRef: React.MutableRefObject<Tab[]>
activeTabPathRef: React.MutableRefObject<string | null>
setTabs: React.Dispatch<React.SetStateAction<Tab[]>>
setActiveTabPath: React.Dispatch<React.SetStateAction<string | null>>
hasUnsavedChanges?: (path: string) => boolean
onMissingActiveVault?: (entry: VaultEntry, error: unknown) => void | Promise<void>
2026-04-27 00:36:24 +02:00
onMissingNotePath?: (entry: VaultEntry, error: unknown) => void | Promise<void>
onUnreadableNoteContent?: (entry: VaultEntry, error: unknown) => void | Promise<void>
}
function syncActiveTabPath(
activeTabPathRef: React.MutableRefObject<string | null>,
setActiveTabPath: React.Dispatch<React.SetStateAction<string | null>>,
path: string | null,
) {
activeTabPathRef.current = path
setActiveTabPath(path)
}
function resetRequestedPathIfStillPending(
requestedActiveTabPathRef: React.MutableRefObject<string | null>,
activeTabPathRef: React.MutableRefObject<string | null>,
pendingPath: string,
) {
if (requestedActiveTabPathRef.current === pendingPath) {
requestedActiveTabPathRef.current = activeTabPathRef.current
}
}
function setSingleTab(
tabsRef: React.MutableRefObject<Tab[]>,
setTabs: React.Dispatch<React.SetStateAction<Tab[]>>,
nextTab: Tab,
) {
tabsRef.current = [nextTab]
setTabs([nextTab])
}
2026-04-22 20:18:26 +02:00
function clearTabs(
tabsRef: React.MutableRefObject<Tab[]>,
setTabs: React.Dispatch<React.SetStateAction<Tab[]>>,
) {
tabsRef.current = []
setTabs([])
}
2026-05-07 06:41:50 +02:00
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<Tab[]>,
activeTabPathRef: React.MutableRefObject<string | null>,
path: string,
) {
return notePathsMatch(activeTabPathRef.current, path)
|| tabsRef.current.some((tab) => notePathsMatch(tab.entry.path, path))
}
2026-04-19 11:29:04 +02:00
function startEntryNavigation(options: {
entry: VaultEntry
navSeqRef: React.MutableRefObject<number>
activeTabPathRef: React.MutableRefObject<string | null>
setActiveTabPath: React.Dispatch<React.SetStateAction<string | null>>
}) {
const {
entry,
navSeqRef,
activeTabPathRef,
setActiveTabPath,
} = options
const seq = ++navSeqRef.current
2026-04-29 10:08:33 +02:00
const cachedEntry = getCachedNoteContentEntry(entry.path)
2026-04-19 11:29:04 +02:00
syncActiveTabPath(activeTabPathRef, setActiveTabPath, entry.path)
2026-04-29 10:08:33 +02:00
if (hasResolvedCachedContent(cachedEntry)) {
markNoteOpenTrace(entry.path, 'cacheReady')
2026-04-19 11:29:04 +02:00
}
2026-04-29 10:08:33 +02:00
return { seq, cachedEntry }
2026-04-19 11:29:04 +02:00
}
2026-04-27 00:36:24 +02:00
function openBinaryEntry(options: {
entry: VaultEntry
navSeqRef: React.MutableRefObject<number>
tabsRef: React.MutableRefObject<Tab[]>
activeTabPathRef: React.MutableRefObject<string | null>
setTabs: React.Dispatch<React.SetStateAction<Tab[]>>
setActiveTabPath: React.Dispatch<React.SetStateAction<string | null>>
}) {
const {
entry,
navSeqRef,
tabsRef,
activeTabPathRef,
setTabs,
setActiveTabPath,
} = options
navSeqRef.current += 1
syncActiveTabPath(activeTabPathRef, setActiveTabPath, entry.path)
setSingleTab(tabsRef, setTabs, { entry, content: '' })
finishNoteOpenTrace(entry.path)
}
2026-04-22 20:18:26 +02:00
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)
}
2026-04-19 11:29:04 +02:00
function shouldApplyLoadedEntry(options: {
seq: number
navSeqRef: React.MutableRefObject<number>
content: string
2026-04-20 14:43:52 +02:00
forceReload: boolean
2026-04-19 11:29:04 +02:00
activeTabPathRef: React.MutableRefObject<string | null>
2026-04-29 10:08:33 +02:00
tabsRef: React.MutableRefObject<Tab[]>
2026-04-19 11:29:04 +02:00
path: string
}) {
const {
seq,
navSeqRef,
content,
2026-04-20 14:43:52 +02:00
forceReload,
2026-04-19 11:29:04 +02:00
activeTabPathRef,
2026-04-29 10:08:33 +02:00
tabsRef,
2026-04-19 11:29:04 +02:00
path,
} = options
if (navSeqRef.current !== seq) return false
2026-04-20 14:43:52 +02:00
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
2026-04-19 11:29:04 +02:00
}
type EntryLoadFailureKind =
| 'missing-active-vault'
| 'missing-path'
| 'unreadable-content'
| 'load-failed'
type RecoverableEntryLoadFailureKind = Exclude<EntryLoadFailureKind, 'load-failed'>
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<Tab[]>
activeTabPathRef: React.MutableRefObject<string | null>
setTabs: React.Dispatch<React.SetStateAction<Tab[]>>
setActiveTabPath: React.Dispatch<React.SetStateAction<string | null>>
}) {
const { tabsRef, activeTabPathRef, setTabs, setActiveTabPath } = options
clearTabs(tabsRef, setTabs)
syncActiveTabPath(activeTabPathRef, setActiveTabPath, null)
}
function runEntryFailureCallback(options: {
callback?: (entry: VaultEntry, error: unknown) => void | Promise<void>
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
2026-05-07 06:41:50 +02:00
callbackEntry: VaultEntry
tabsRef: React.MutableRefObject<Tab[]>
activeTabPathRef: React.MutableRefObject<string | null>
setTabs: React.Dispatch<React.SetStateAction<Tab[]>>
setActiveTabPath: React.Dispatch<React.SetStateAction<string | null>>
error: unknown
onMissingActiveVault?: (entry: VaultEntry, error: unknown) => void | Promise<void>
onMissingNotePath?: (entry: VaultEntry, error: unknown) => void | Promise<void>
onUnreadableNoteContent?: (entry: VaultEntry, error: unknown) => void | Promise<void>
}) {
const {
kind,
entry,
2026-05-07 06:41:50 +02:00
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,
2026-05-07 06:41:50 +02:00
entry: callbackEntry,
error,
warning: 'Failed to handle missing active vault:',
})
return
}
if (kind === 'missing-path') {
runEntryFailureCallback({
callback: onMissingNotePath,
2026-05-07 06:41:50 +02:00
entry: callbackEntry,
error,
warning: 'Failed to handle missing note path:',
})
return
}
if (kind === 'unreadable-content') {
runEntryFailureCallback({
callback: onUnreadableNoteContent,
2026-05-07 06:41:50 +02:00
entry: callbackEntry,
error,
warning: 'Failed to handle unreadable note content:',
})
}
}
2026-04-19 11:29:04 +02:00
function handleEntryLoadFailure(options: {
entry: VaultEntry
2026-05-07 06:41:50 +02:00
callbackEntry: VaultEntry
2026-04-19 11:29:04 +02:00
seq: number
navSeqRef: React.MutableRefObject<number>
tabsRef: React.MutableRefObject<Tab[]>
2026-04-22 20:18:26 +02:00
activeTabPathRef: React.MutableRefObject<string | null>
2026-04-19 11:29:04 +02:00
setTabs: React.Dispatch<React.SetStateAction<Tab[]>>
2026-04-22 20:18:26 +02:00
setActiveTabPath: React.Dispatch<React.SetStateAction<string | null>>
2026-04-19 11:29:04 +02:00
error: unknown
onMissingActiveVault?: (entry: VaultEntry, error: unknown) => void | Promise<void>
2026-04-22 20:18:26 +02:00
onMissingNotePath?: (entry: VaultEntry, error: unknown) => void | Promise<void>
onUnreadableNoteContent?: (entry: VaultEntry, error: unknown) => void | Promise<void>
2026-04-19 11:29:04 +02:00
}) {
const {
entry,
2026-05-07 06:41:50 +02:00
callbackEntry,
2026-04-19 11:29:04 +02:00
seq,
navSeqRef,
tabsRef,
2026-04-22 20:18:26 +02:00
activeTabPathRef,
2026-04-19 11:29:04 +02:00
setTabs,
2026-04-22 20:18:26 +02:00
setActiveTabPath,
2026-04-19 11:29:04 +02:00
error,
onMissingActiveVault,
2026-04-22 20:18:26 +02:00
onMissingNotePath,
onUnreadableNoteContent,
2026-04-19 11:29:04 +02:00
} = 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,
2026-05-07 06:41:50 +02:00
callbackEntry,
tabsRef,
activeTabPathRef,
setTabs,
setActiveTabPath,
error,
onMissingActiveVault,
onMissingNotePath,
onUnreadableNoteContent,
2026-04-22 20:18:26 +02:00
})
return
}
resetFailedEntrySelection({
tabsRef,
activeTabPathRef,
setTabs,
setActiveTabPath,
})
failNoteOpenTrace(entry.path, 'load-failed')
2026-04-19 11:29:04 +02:00
}
2026-04-27 00:36:24 +02:00
function reopenAlreadyViewingEntry({
entry,
tabsRef,
activeTabPathRef,
setActiveTabPath,
hasUnsavedChanges,
}: Pick<NavigateToEntryOptions, 'entry' | 'tabsRef' | 'activeTabPathRef' | 'setActiveTabPath' | 'hasUnsavedChanges'>): boolean {
2026-04-27 00:36:24 +02:00
if (!isAlreadyViewingPath(tabsRef, activeTabPathRef, entry.path)) return false
if (!hasUnsavedChanges?.(entry.path)) return false
2026-04-27 00:36:24 +02:00
syncActiveTabPath(activeTabPathRef, setActiveTabPath, entry.path)
finishNoteOpenTrace(entry.path)
return true
}
async function loadTextEntry(options: Required<Pick<NavigateToEntryOptions, 'forceReload'>> & NavigateToEntryOptions) {
const {
entry,
2026-05-07 06:41:50 +02:00
sourceEntry,
2026-04-27 00:36:24 +02:00
forceReload,
navSeqRef,
tabsRef,
activeTabPathRef,
setTabs,
setActiveTabPath,
onMissingActiveVault,
2026-04-22 20:18:26 +02:00
onMissingNotePath,
onUnreadableNoteContent,
} = options
2026-04-29 10:08:33 +02:00
const { seq, cachedEntry } = startEntryNavigation({
2026-04-19 11:29:04 +02:00
entry,
navSeqRef,
activeTabPathRef,
setActiveTabPath,
})
try {
markNoteOpenTrace(entry.path, 'contentLoadStart')
2026-04-29 10:08:33 +02:00
const content = await loadContentForOpen({
entry,
2026-04-29 10:08:33 +02:00
forceReload,
cachedEntry,
})
markNoteOpenTrace(entry.path, 'contentLoadEnd')
2026-04-19 11:29:04 +02:00
if (!shouldApplyLoadedEntry({
seq,
navSeqRef,
content,
2026-04-20 14:43:52 +02:00
forceReload,
2026-04-19 11:29:04 +02:00
activeTabPathRef,
2026-04-29 10:08:33 +02:00
tabsRef,
2026-04-19 11:29:04 +02:00
path: entry.path,
})) return
setSingleTab(tabsRef, setTabs, { entry, content })
} catch (err) {
2026-04-19 11:29:04 +02:00
handleEntryLoadFailure({
entry,
2026-05-07 06:41:50 +02:00
callbackEntry: callbackEntryForLoadFailure(entry, sourceEntry),
2026-04-19 11:29:04 +02:00
seq,
navSeqRef,
tabsRef,
2026-04-22 20:18:26 +02:00
activeTabPathRef,
2026-04-19 11:29:04 +02:00
setTabs,
2026-04-22 20:18:26 +02:00
setActiveTabPath,
2026-04-19 11:29:04 +02:00
error: err,
onMissingActiveVault,
2026-04-22 20:18:26 +02:00
onMissingNotePath,
onUnreadableNoteContent,
2026-04-19 11:29:04 +02:00
})
}
}
2026-04-27 00:36:24 +02:00
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<Tab[]>([])
const [activeTabPath, setActiveTabPath] = useState<string | null>(null)
const activeTabPathRef = useRef(activeTabPath)
const requestedActiveTabPathRef = useRef<string | null>(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
2026-04-22 20:18:26 +02:00
const onMissingNotePath = options.onMissingNotePath
const onUnreadableNoteContent = options.onUnreadableNoteContent
const executeNavigationWithBoundary = useCallback(async (
targetPath: string,
navigate: () => void | Promise<void>,
) => {
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) => {
2026-05-07 06:41:50 +02:00
const openEntry = normalizeOpenEntry(entry)
if (!openEntry) return
requestedActiveTabPathRef.current = openEntry.path
const alreadyViewingDirtyEntry = notePathsMatch(openEntry.path, activeTabPathRef.current)
&& !!hasUnsavedChanges?.(openEntry.path)
if (!alreadyViewingDirtyEntry) {
2026-05-07 06:41:50 +02:00
beginNoteOpenTrace(openEntry.path, 'select-note')
}
2026-05-07 06:41:50 +02:00
const navigated = await executeNavigationWithBoundary(openEntry.path, () => navigateToEntry({
entry: openEntry,
sourceEntry: entry,
navSeqRef,
tabsRef,
activeTabPathRef,
setTabs,
setActiveTabPath,
hasUnsavedChanges,
onMissingActiveVault,
2026-04-22 20:18:26 +02:00
onMissingNotePath,
onUnreadableNoteContent,
}))
if (!navigated) {
2026-05-07 06:41:50 +02:00
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) => {
2026-05-07 06:41:50 +02:00
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) => {
2026-05-07 06:41:50 +02:00
if (!navigated) resetRequestedPathIfStillPending(requestedActiveTabPathRef, activeTabPathRef, openEntry.path)
})
}, [executeNavigationWithBoundary])
const handleReplaceActiveTab = useCallback(async (entry: VaultEntry) => {
2026-05-07 06:41:50 +02:00
const openEntry = normalizeOpenEntry(entry)
if (!openEntry) return
requestedActiveTabPathRef.current = openEntry.path
const replacingDifferentEntry = !notePathsMatch(openEntry.path, activeTabPathRef.current)
if (replacingDifferentEntry) {
2026-05-07 06:41:50 +02:00
beginNoteOpenTrace(openEntry.path, 'replace-active-tab')
}
2026-05-07 06:41:50 +02:00
const navigated = await executeNavigationWithBoundary(openEntry.path, () => navigateToEntry({
entry: openEntry,
sourceEntry: entry,
forceReload: !replacingDifferentEntry,
navSeqRef,
tabsRef,
activeTabPathRef,
setTabs,
setActiveTabPath,
onMissingActiveVault,
2026-04-22 20:18:26 +02:00
onMissingNotePath,
onUnreadableNoteContent,
}))
if (!navigated) {
2026-05-07 06:41:50 +02:00
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,
}
}