2026-02-22 10:11:52 +01:00
|
|
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
|
|
|
import type { VaultEntry } from '../types'
|
2026-04-19 19:30:58 +02:00
|
|
|
import {
|
|
|
|
|
beginNoteOpenTrace,
|
|
|
|
|
failNoteOpenTrace,
|
|
|
|
|
finishNoteOpenTrace,
|
|
|
|
|
markNoteOpenTrace,
|
|
|
|
|
} from '../utils/noteOpenPerformance'
|
2026-05-01 23:14:36 +02:00
|
|
|
import {
|
|
|
|
|
cacheNoteContent as cacheNoteContentInMemory,
|
|
|
|
|
clearNoteContentCache,
|
|
|
|
|
getCachedNoteContentEntry,
|
|
|
|
|
hasResolvedCachedContent,
|
|
|
|
|
isNoActiveVaultSelectedError,
|
|
|
|
|
isUnreadableNoteContentError,
|
|
|
|
|
loadContentForOpen,
|
|
|
|
|
NOTE_CONTENT_CACHE_LIMIT,
|
|
|
|
|
NOTE_CONTENT_CACHE_MAX_BYTES,
|
|
|
|
|
NOTE_CONTENT_ENTRY_MAX_BYTES,
|
2026-05-05 19:11:17 +02:00
|
|
|
NOTE_CONTENT_PREFETCH_CONCURRENCY,
|
2026-05-01 23:14:36 +02:00
|
|
|
prefetchNoteContent as prefetchNoteContentInMemory,
|
|
|
|
|
} from './noteContentCache'
|
|
|
|
|
import { clearParsedNoteBlockCache } from './editorParsedBlockCache'
|
2026-05-04 04:59:50 +02:00
|
|
|
import { notePathsMatch } from '../utils/notePathIdentity'
|
2026-05-07 06:41:50 +02:00
|
|
|
import { normalizeVaultEntry } from '../utils/vaultMetadataNormalization'
|
2026-02-22 10:11:52 +01:00
|
|
|
|
|
|
|
|
interface Tab {
|
|
|
|
|
entry: VaultEntry
|
|
|
|
|
content: string
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-01 23:14:36 +02:00
|
|
|
export {
|
|
|
|
|
NOTE_CONTENT_CACHE_LIMIT,
|
|
|
|
|
NOTE_CONTENT_CACHE_MAX_BYTES,
|
|
|
|
|
NOTE_CONTENT_ENTRY_MAX_BYTES,
|
2026-05-05 19:11:17 +02:00
|
|
|
NOTE_CONTENT_PREFETCH_CONCURRENCY,
|
2026-04-24 21:53:47 +02:00
|
|
|
}
|
2026-04-19 11:29:04 +02:00
|
|
|
|
2026-05-01 23:14:36 +02:00
|
|
|
export function prefetchNoteContent(target: string | VaultEntry): void {
|
|
|
|
|
prefetchNoteContentInMemory(target)
|
2026-04-24 21:53:47 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-01 23:14:36 +02:00
|
|
|
export function cacheNoteContent(path: string, content: string, entry?: VaultEntry): void {
|
|
|
|
|
cacheNoteContentInMemory(path, content, entry)
|
2026-04-24 23:29:44 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-01 23:14:36 +02:00
|
|
|
/** Clear note-open caches. Call on vault reload to prevent stale content. */
|
2026-03-09 13:05:18 +01:00
|
|
|
export function clearPrefetchCache(): void {
|
2026-05-01 23:14:36 +02:00
|
|
|
clearNoteContentCache()
|
|
|
|
|
clearParsedNoteBlockCache()
|
2026-04-29 10:08:33 +02:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 10:11:52 +01:00
|
|
|
export type { Tab }
|
|
|
|
|
|
2026-04-18 09:19:29 +02:00
|
|
|
interface TabManagementOptions {
|
|
|
|
|
beforeNavigate?: (fromPath: string, toPath: string) => Promise<void>
|
2026-04-29 11:42:37 +02:00
|
|
|
hasUnsavedChanges?: (path: string) => boolean
|
2026-05-02 17:40:58 +02:00
|
|
|
onMissingActiveVault?: (entry: VaultEntry, error: unknown) => void | Promise<void>
|
2026-04-22 20:18:26 +02:00
|
|
|
onMissingNotePath?: (entry: VaultEntry, error: unknown) => void | Promise<void>
|
2026-04-24 03:02:03 +02:00
|
|
|
onUnreadableNoteContent?: (entry: VaultEntry, error: unknown) => void | Promise<void>
|
2026-04-18 09:19:29 +02:00
|
|
|
}
|
|
|
|
|
|
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>>
|
2026-04-29 11:42:37 +02:00
|
|
|
hasUnsavedChanges?: (path: string) => boolean
|
2026-05-02 17:40:58 +02:00
|
|
|
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>
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-09 12:20:03 +02:00
|
|
|
function syncActiveTabPath(
|
|
|
|
|
activeTabPathRef: React.MutableRefObject<string | null>,
|
|
|
|
|
setActiveTabPath: React.Dispatch<React.SetStateAction<string | null>>,
|
|
|
|
|
path: string | null,
|
|
|
|
|
) {
|
|
|
|
|
activeTabPathRef.current = path
|
|
|
|
|
setActiveTabPath(path)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 23:26:31 +02:00
|
|
|
function resetRequestedPathIfStillPending(
|
|
|
|
|
requestedActiveTabPathRef: React.MutableRefObject<string | null>,
|
|
|
|
|
activeTabPathRef: React.MutableRefObject<string | null>,
|
|
|
|
|
pendingPath: string,
|
|
|
|
|
) {
|
|
|
|
|
if (requestedActiveTabPathRef.current === pendingPath) {
|
|
|
|
|
requestedActiveTabPathRef.current = activeTabPathRef.current
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-09 12:20:03 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-09 12:20:03 +02:00
|
|
|
function isAlreadyViewingPath(
|
|
|
|
|
tabsRef: React.MutableRefObject<Tab[]>,
|
|
|
|
|
activeTabPathRef: React.MutableRefObject<string | null>,
|
|
|
|
|
path: string,
|
|
|
|
|
) {
|
2026-05-04 04:59:50 +02:00
|
|
|
return notePathsMatch(activeTabPathRef.current, path)
|
|
|
|
|
|| tabsRef.current.some((tab) => notePathsMatch(tab.entry.path, path))
|
2026-04-09 12:20:03 +02:00
|
|
|
}
|
|
|
|
|
|
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)) {
|
2026-04-19 19:30:58 +02:00
|
|
|
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>
|
2026-04-29 11:42:37 +02:00
|
|
|
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,
|
2026-04-29 11:42:37 +02:00
|
|
|
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
|
2026-05-04 04:59:50 +02:00
|
|
|
if (!notePathsMatch(activeTabPathRef.current, path)) return true
|
|
|
|
|
const openTab = tabsRef.current.find((tab) => notePathsMatch(tab.entry.path, path))
|
2026-04-29 11:42:37 +02:00
|
|
|
return !openTab || openTab.content !== content
|
2026-04-19 11:29:04 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-24 03:02:03 +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
|
2026-04-24 03:02:03 +02:00
|
|
|
tabsRef: React.MutableRefObject<Tab[]>
|
|
|
|
|
activeTabPathRef: React.MutableRefObject<string | null>
|
|
|
|
|
setTabs: React.Dispatch<React.SetStateAction<Tab[]>>
|
|
|
|
|
setActiveTabPath: React.Dispatch<React.SetStateAction<string | null>>
|
|
|
|
|
error: unknown
|
2026-05-02 17:40:58 +02:00
|
|
|
onMissingActiveVault?: (entry: VaultEntry, error: unknown) => void | Promise<void>
|
2026-04-24 03:02:03 +02:00
|
|
|
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,
|
2026-04-24 03:02:03 +02:00
|
|
|
tabsRef,
|
|
|
|
|
activeTabPathRef,
|
|
|
|
|
setTabs,
|
|
|
|
|
setActiveTabPath,
|
|
|
|
|
error,
|
2026-05-02 17:40:58 +02:00
|
|
|
onMissingActiveVault,
|
2026-04-24 03:02:03 +02:00
|
|
|
onMissingNotePath,
|
|
|
|
|
onUnreadableNoteContent,
|
|
|
|
|
} = options
|
|
|
|
|
|
|
|
|
|
if (kind === 'missing-active-vault') {
|
|
|
|
|
clearPrefetchCache()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resetFailedEntrySelection({
|
|
|
|
|
tabsRef,
|
|
|
|
|
activeTabPathRef,
|
|
|
|
|
setTabs,
|
|
|
|
|
setActiveTabPath,
|
|
|
|
|
})
|
|
|
|
|
failNoteOpenTrace(entry.path, kind)
|
|
|
|
|
|
2026-05-02 17:40:58 +02:00
|
|
|
if (kind === 'missing-active-vault') {
|
|
|
|
|
runEntryFailureCallback({
|
|
|
|
|
callback: onMissingActiveVault,
|
2026-05-07 06:41:50 +02:00
|
|
|
entry: callbackEntry,
|
2026-05-02 17:40:58 +02:00
|
|
|
error,
|
|
|
|
|
warning: 'Failed to handle missing active vault:',
|
|
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 03:02:03 +02:00
|
|
|
if (kind === 'missing-path') {
|
|
|
|
|
runEntryFailureCallback({
|
|
|
|
|
callback: onMissingNotePath,
|
2026-05-07 06:41:50 +02:00
|
|
|
entry: callbackEntry,
|
2026-04-24 03:02:03 +02:00
|
|
|
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,
|
2026-04-24 03:02:03 +02:00
|
|
|
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
|
2026-05-02 17:40:58 +02:00
|
|
|
onMissingActiveVault?: (entry: VaultEntry, error: unknown) => void | Promise<void>
|
2026-04-22 20:18:26 +02:00
|
|
|
onMissingNotePath?: (entry: VaultEntry, error: unknown) => void | Promise<void>
|
2026-04-24 03:02:03 +02:00
|
|
|
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,
|
2026-05-02 17:40:58 +02:00
|
|
|
onMissingActiveVault,
|
2026-04-22 20:18:26 +02:00
|
|
|
onMissingNotePath,
|
2026-04-24 03:02:03 +02:00
|
|
|
onUnreadableNoteContent,
|
2026-04-19 11:29:04 +02:00
|
|
|
} = options
|
|
|
|
|
|
|
|
|
|
console.warn('Failed to load note content:', error)
|
|
|
|
|
if (navSeqRef.current !== seq) return
|
2026-04-24 03:02:03 +02:00
|
|
|
|
|
|
|
|
const failureKind = getEntryLoadFailureKind(error)
|
|
|
|
|
if (failureKind !== 'load-failed') {
|
|
|
|
|
handleRecoverableEntryLoadFailure({
|
|
|
|
|
kind: failureKind,
|
|
|
|
|
entry,
|
2026-05-07 06:41:50 +02:00
|
|
|
callbackEntry,
|
2026-04-24 03:02:03 +02:00
|
|
|
tabsRef,
|
|
|
|
|
activeTabPathRef,
|
|
|
|
|
setTabs,
|
|
|
|
|
setActiveTabPath,
|
|
|
|
|
error,
|
2026-05-02 17:40:58 +02:00
|
|
|
onMissingActiveVault,
|
2026-04-24 03:02:03 +02:00
|
|
|
onMissingNotePath,
|
|
|
|
|
onUnreadableNoteContent,
|
2026-04-22 20:18:26 +02:00
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-04-24 03:02:03 +02:00
|
|
|
|
2026-05-13 10:29:22 +02:00
|
|
|
resetFailedEntrySelection({
|
|
|
|
|
tabsRef,
|
|
|
|
|
activeTabPathRef,
|
|
|
|
|
setTabs,
|
|
|
|
|
setActiveTabPath,
|
|
|
|
|
})
|
2026-04-19 19:30:58 +02:00
|
|
|
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,
|
2026-04-29 11:42:37 +02:00
|
|
|
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
|
2026-04-29 11:42:37 +02:00
|
|
|
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) {
|
2026-04-09 12:20:03 +02:00
|
|
|
const {
|
|
|
|
|
entry,
|
2026-05-07 06:41:50 +02:00
|
|
|
sourceEntry,
|
2026-04-27 00:36:24 +02:00
|
|
|
forceReload,
|
2026-04-09 12:20:03 +02:00
|
|
|
navSeqRef,
|
|
|
|
|
tabsRef,
|
|
|
|
|
activeTabPathRef,
|
|
|
|
|
setTabs,
|
|
|
|
|
setActiveTabPath,
|
2026-05-02 17:40:58 +02:00
|
|
|
onMissingActiveVault,
|
2026-04-22 20:18:26 +02:00
|
|
|
onMissingNotePath,
|
2026-04-24 03:02:03 +02:00
|
|
|
onUnreadableNoteContent,
|
2026-04-09 12:20:03 +02:00
|
|
|
} = 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,
|
|
|
|
|
})
|
2026-04-09 12:20:03 +02:00
|
|
|
|
|
|
|
|
try {
|
2026-04-19 19:30:58 +02:00
|
|
|
markNoteOpenTrace(entry.path, 'contentLoadStart')
|
2026-04-29 10:08:33 +02:00
|
|
|
const content = await loadContentForOpen({
|
2026-05-01 23:14:36 +02:00
|
|
|
entry,
|
2026-04-29 10:08:33 +02:00
|
|
|
forceReload,
|
|
|
|
|
cachedEntry,
|
|
|
|
|
})
|
2026-04-19 19:30:58 +02:00
|
|
|
markNoteOpenTrace(entry.path, 'contentLoadEnd')
|
2026-04-19 11:29:04 +02:00
|
|
|
if (!shouldApplyLoadedEntry({
|
|
|
|
|
seq,
|
|
|
|
|
navSeqRef,
|
2026-04-29 11:42:37 +02:00
|
|
|
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
|
2026-04-09 12:20:03 +02:00
|
|
|
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,
|
2026-05-02 17:40:58 +02:00
|
|
|
onMissingActiveVault,
|
2026-04-22 20:18:26 +02:00
|
|
|
onMissingNotePath,
|
2026-04-24 03:02:03 +02:00
|
|
|
onUnreadableNoteContent,
|
2026-04-19 11:29:04 +02:00
|
|
|
})
|
2026-04-09 12:20:03 +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 })
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-18 09:19:29 +02:00
|
|
|
export function useTabManagement(options: TabManagementOptions = {}) {
|
refactor: remove tab bar — single note open at a time
Replace the multi-tab model with single-note navigation. One note is
open at a time; clicking any note (sidebar, wikilink, relationship)
replaces the current note in the editor. Back/Forward history still
works via Cmd+[/].
Removed: TabBar component, useClosedTabHistory, tab reorder/close/
reopen logic, Cmd+W/Cmd+Shift+T shortcuts, Close Tab and Reopen
Closed Tab menu items, tabLayout utility, and all related tests.
Simplified: useTabManagement (single-note), useAppNavigation (no tab
lookup), useKeyboardNavigation (note nav only), useNoteCreation (no
close-tab cleanup), useDeleteActions (deselect instead of close tab),
Editor (no TabBar render), Rust menu (removed 2 menu items).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:24:49 +01:00
|
|
|
// Single-note model: tabs has 0 or 1 elements.
|
2026-02-22 10:11:52 +01:00
|
|
|
const [tabs, setTabs] = useState<Tab[]>([])
|
|
|
|
|
const [activeTabPath, setActiveTabPath] = useState<string | null>(null)
|
|
|
|
|
const activeTabPathRef = useRef(activeTabPath)
|
2026-04-29 23:26:31 +02:00
|
|
|
const requestedActiveTabPathRef = useRef<string | null>(activeTabPath)
|
fix: resolve React hooks/compiler ESLint errors
- Wrap ref assignments in useEffect to fix refs-during-render in
useTabManagement, useKeyboardNavigation, and Editor
- Rewrite useAppKeyboard to define keyMap inside useEffect, eliminating
ref access during render
- Add eslint-disable for react-hooks/set-state-in-effect on legitimate
dialog reset patterns (CommitDialog, CreateNoteDialog, CreateTypeDialog,
QuickOpenPalette, AIChatPanel, useVaultLoader)
- Add eslint-disable for react-hooks/static-components on icon lookups
(NoteItem, NoteList PinnedCard) — stateless icon components from a
static map
- Add eslint-disable for react-hooks/purity on Date.now() in
NoteItem TrashDateLine — intentionally memoized on trashedAt
- Remove unused `model` param from buildSystemPrompt (ai-chat.ts) and
update callers
- Fix exhaustive-deps: suppress vault object dep in App.tsx git history
effect (vault object is unstable, loadGitHistory is the real dep)
- Remove unused `modifiedFiles` from useNoteListData params and caller
- Add missing `ref` to Sidebar useOutsideClick deps
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 14:06:51 +01:00
|
|
|
useEffect(() => { activeTabPathRef.current = activeTabPath })
|
2026-02-22 10:11:52 +01:00
|
|
|
const tabsRef = useRef(tabs)
|
fix: resolve React hooks/compiler ESLint errors
- Wrap ref assignments in useEffect to fix refs-during-render in
useTabManagement, useKeyboardNavigation, and Editor
- Rewrite useAppKeyboard to define keyMap inside useEffect, eliminating
ref access during render
- Add eslint-disable for react-hooks/set-state-in-effect on legitimate
dialog reset patterns (CommitDialog, CreateNoteDialog, CreateTypeDialog,
QuickOpenPalette, AIChatPanel, useVaultLoader)
- Add eslint-disable for react-hooks/static-components on icon lookups
(NoteItem, NoteList PinnedCard) — stateless icon components from a
static map
- Add eslint-disable for react-hooks/purity on Date.now() in
NoteItem TrashDateLine — intentionally memoized on trashedAt
- Remove unused `model` param from buildSystemPrompt (ai-chat.ts) and
update callers
- Fix exhaustive-deps: suppress vault object dep in App.tsx git history
effect (vault object is unstable, loadGitHistory is the real dep)
- Remove unused `modifiedFiles` from useNoteListData params and caller
- Add missing `ref` to Sidebar useOutsideClick deps
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 14:06:51 +01:00
|
|
|
useEffect(() => { tabsRef.current = tabs })
|
2026-02-22 10:11:52 +01:00
|
|
|
|
2026-03-09 13:05:18 +01:00
|
|
|
// Sequence counter for rapid-switch safety: only the latest navigation wins.
|
|
|
|
|
const navSeqRef = useRef(0)
|
2026-04-18 09:19:29 +02:00
|
|
|
const beforeNavigateSeqRef = useRef(0)
|
|
|
|
|
const beforeNavigate = options.beforeNavigate
|
2026-04-29 11:42:37 +02:00
|
|
|
const hasUnsavedChanges = options.hasUnsavedChanges
|
2026-05-02 17:40:58 +02:00
|
|
|
const onMissingActiveVault = options.onMissingActiveVault
|
2026-04-22 20:18:26 +02:00
|
|
|
const onMissingNotePath = options.onMissingNotePath
|
2026-04-24 03:02:03 +02:00
|
|
|
const onUnreadableNoteContent = options.onUnreadableNoteContent
|
2026-04-18 09:19:29 +02:00
|
|
|
|
|
|
|
|
const executeNavigationWithBoundary = useCallback(async (
|
|
|
|
|
targetPath: string,
|
|
|
|
|
navigate: () => void | Promise<void>,
|
|
|
|
|
) => {
|
|
|
|
|
const seq = ++beforeNavigateSeqRef.current
|
|
|
|
|
const currentPath = activeTabPathRef.current
|
2026-05-04 04:59:50 +02:00
|
|
|
if (beforeNavigate && currentPath && !notePathsMatch(currentPath, targetPath)) {
|
2026-04-18 09:19:29 +02:00
|
|
|
try {
|
2026-04-19 19:30:58 +02:00
|
|
|
markNoteOpenTrace(targetPath, 'beforeNavigateStart')
|
2026-04-18 09:19:29 +02:00
|
|
|
await beforeNavigate(currentPath, targetPath)
|
2026-04-19 19:30:58 +02:00
|
|
|
markNoteOpenTrace(targetPath, 'beforeNavigateEnd')
|
2026-04-18 09:19:29 +02:00
|
|
|
} catch (err) {
|
|
|
|
|
console.warn('Failed to persist note before navigation:', err)
|
2026-04-19 19:30:58 +02:00
|
|
|
failNoteOpenTrace(targetPath, 'before-navigate-failed')
|
2026-04-29 23:26:31 +02:00
|
|
|
return false
|
2026-04-18 09:19:29 +02:00
|
|
|
}
|
2026-04-29 23:26:31 +02:00
|
|
|
if (beforeNavigateSeqRef.current !== seq) return false
|
2026-04-18 09:19:29 +02:00
|
|
|
}
|
|
|
|
|
await navigate()
|
2026-04-29 23:26:31 +02:00
|
|
|
return true
|
2026-04-18 09:19:29 +02:00
|
|
|
}, [beforeNavigate])
|
2026-03-09 13:05:18 +01:00
|
|
|
|
refactor: remove tab bar — single note open at a time
Replace the multi-tab model with single-note navigation. One note is
open at a time; clicking any note (sidebar, wikilink, relationship)
replaces the current note in the editor. Back/Forward history still
works via Cmd+[/].
Removed: TabBar component, useClosedTabHistory, tab reorder/close/
reopen logic, Cmd+W/Cmd+Shift+T shortcuts, Close Tab and Reopen
Closed Tab menu items, tabLayout utility, and all related tests.
Simplified: useTabManagement (single-note), useAppNavigation (no tab
lookup), useKeyboardNavigation (note nav only), useNoteCreation (no
close-tab cleanup), useDeleteActions (deselect instead of close tab),
Editor (no TabBar render), Rust menu (removed 2 menu items).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:24:49 +01:00
|
|
|
/** Open a note — replaces the current note (single-note model). */
|
2026-02-22 10:11:52 +01:00
|
|
|
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)
|
2026-04-29 11:42:37 +02:00
|
|
|
if (!alreadyViewingDirtyEntry) {
|
2026-05-07 06:41:50 +02:00
|
|
|
beginNoteOpenTrace(openEntry.path, 'select-note')
|
2026-04-19 19:30:58 +02:00
|
|
|
}
|
2026-05-07 06:41:50 +02:00
|
|
|
const navigated = await executeNavigationWithBoundary(openEntry.path, () => navigateToEntry({
|
|
|
|
|
entry: openEntry,
|
|
|
|
|
sourceEntry: entry,
|
2026-04-09 12:20:03 +02:00
|
|
|
navSeqRef,
|
|
|
|
|
tabsRef,
|
|
|
|
|
activeTabPathRef,
|
|
|
|
|
setTabs,
|
|
|
|
|
setActiveTabPath,
|
2026-04-29 11:42:37 +02:00
|
|
|
hasUnsavedChanges,
|
2026-05-02 17:40:58 +02:00
|
|
|
onMissingActiveVault,
|
2026-04-22 20:18:26 +02:00
|
|
|
onMissingNotePath,
|
2026-04-24 03:02:03 +02:00
|
|
|
onUnreadableNoteContent,
|
2026-04-18 09:19:29 +02:00
|
|
|
}))
|
2026-04-29 23:26:31 +02:00
|
|
|
if (!navigated) {
|
2026-05-07 06:41:50 +02:00
|
|
|
resetRequestedPathIfStillPending(requestedActiveTabPathRef, activeTabPathRef, openEntry.path)
|
2026-04-29 23:26:31 +02:00
|
|
|
}
|
2026-05-02 17:40:58 +02:00
|
|
|
}, [executeNavigationWithBoundary, hasUnsavedChanges, onMissingActiveVault, onMissingNotePath, onUnreadableNoteContent])
|
2026-02-22 10:11:52 +01:00
|
|
|
|
2026-04-09 12:20:03 +02:00
|
|
|
const handleSwitchTab = useCallback((path: string) => {
|
2026-04-29 23:26:31 +02:00
|
|
|
requestedActiveTabPathRef.current = path
|
2026-04-09 12:20:03 +02:00
|
|
|
syncActiveTabPath(activeTabPathRef, setActiveTabPath, path)
|
|
|
|
|
}, [])
|
2026-02-22 10:11:52 +01:00
|
|
|
|
2026-02-24 23:03:01 +01:00
|
|
|
/** 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)
|
2026-04-29 23:26:31 +02:00
|
|
|
}).then((navigated) => {
|
2026-05-07 06:41:50 +02:00
|
|
|
if (!navigated) resetRequestedPathIfStillPending(requestedActiveTabPathRef, activeTabPathRef, openEntry.path)
|
2026-04-18 09:19:29 +02:00
|
|
|
})
|
|
|
|
|
}, [executeNavigationWithBoundary])
|
2026-02-24 23:03:01 +01:00
|
|
|
|
2026-02-22 10:11:52 +01:00
|
|
|
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)
|
2026-05-01 23:14:36 +02:00
|
|
|
if (replacingDifferentEntry) {
|
2026-05-07 06:41:50 +02:00
|
|
|
beginNoteOpenTrace(openEntry.path, 'replace-active-tab')
|
2026-04-19 19:30:58 +02:00
|
|
|
}
|
2026-05-07 06:41:50 +02:00
|
|
|
const navigated = await executeNavigationWithBoundary(openEntry.path, () => navigateToEntry({
|
|
|
|
|
entry: openEntry,
|
|
|
|
|
sourceEntry: entry,
|
2026-05-01 23:14:36 +02:00
|
|
|
forceReload: !replacingDifferentEntry,
|
2026-04-09 12:20:03 +02:00
|
|
|
navSeqRef,
|
|
|
|
|
tabsRef,
|
|
|
|
|
activeTabPathRef,
|
|
|
|
|
setTabs,
|
|
|
|
|
setActiveTabPath,
|
2026-05-02 17:40:58 +02:00
|
|
|
onMissingActiveVault,
|
2026-04-22 20:18:26 +02:00
|
|
|
onMissingNotePath,
|
2026-04-24 03:02:03 +02:00
|
|
|
onUnreadableNoteContent,
|
2026-04-18 09:19:29 +02:00
|
|
|
}))
|
2026-04-29 23:26:31 +02:00
|
|
|
if (!navigated) {
|
2026-05-07 06:41:50 +02:00
|
|
|
resetRequestedPathIfStillPending(requestedActiveTabPathRef, activeTabPathRef, openEntry.path)
|
2026-04-29 23:26:31 +02:00
|
|
|
}
|
2026-05-02 17:40:58 +02:00
|
|
|
}, [executeNavigationWithBoundary, onMissingActiveVault, onMissingNotePath, onUnreadableNoteContent])
|
2026-03-11 23:32:20 +01:00
|
|
|
|
2026-02-22 10:11:52 +01:00
|
|
|
const closeAllTabs = useCallback(() => {
|
2026-05-07 00:13:52 +02:00
|
|
|
navSeqRef.current += 1
|
|
|
|
|
beforeNavigateSeqRef.current += 1
|
2026-04-09 12:20:03 +02:00
|
|
|
tabsRef.current = []
|
2026-02-22 10:11:52 +01:00
|
|
|
setTabs([])
|
2026-04-29 23:26:31 +02:00
|
|
|
requestedActiveTabPathRef.current = null
|
2026-04-09 12:20:03 +02:00
|
|
|
syncActiveTabPath(activeTabPathRef, setActiveTabPath, null)
|
2026-02-22 10:11:52 +01:00
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
tabs,
|
|
|
|
|
setTabs,
|
|
|
|
|
activeTabPath,
|
|
|
|
|
activeTabPathRef,
|
2026-04-29 23:26:31 +02:00
|
|
|
requestedActiveTabPathRef,
|
2026-02-22 10:11:52 +01:00
|
|
|
handleSelectNote,
|
2026-02-24 23:03:01 +01:00
|
|
|
openTabWithContent,
|
2026-02-22 10:11:52 +01:00
|
|
|
handleSwitchTab,
|
|
|
|
|
handleReplaceActiveTab,
|
|
|
|
|
closeAllTabs,
|
|
|
|
|
}
|
|
|
|
|
}
|