import { useCallback, useEffect, useRef, type MutableRefObject } from 'react' import type { VaultEntry } from '../types' import type { FrontmatterValue } from '../components/Inspector' import { useTabManagement } from './useTabManagement' import { GITIGNORED_VISIBILITY_APPLIED_EVENT, type GitignoredVisibilityAppliedEvent, } from '../lib/gitignoredVisibilityEvents' import { resolveEntry } from '../utils/wikilink' import { useNoteCreation } from './useNoteCreation' import { useNoteRename, performRename, loadNoteContent, renameToastMessage, reloadTabsAfterRename, reloadVaultAfterRename, } from './useNoteRename' import { runFrontmatterAndApply, type FrontmatterOpOptions } from './frontmatterOps' import { findByNotePath, notePathFilename, notePathsMatch } from '../utils/notePathIdentity' import type { VaultOption } from '../components/status-bar/types' export interface NoteActionsConfig { addEntry: (entry: VaultEntry) => void removeEntry: (path: string) => void entries: VaultEntry[] flushBeforeNoteSwitch?: (path: string) => Promise flushBeforeNoteMutation?: (path: string) => Promise reloadVault?: () => Promise setToastMessage: (msg: string | null) => void updateEntry: (path: string, patch: Partial) => void vaultPath: string defaultWorkspacePath?: string | null vaults?: readonly VaultOption[] addPendingSave?: (path: string) => void removePendingSave?: (path: string) => void trackUnsaved?: (path: string) => void clearUnsaved?: (path: string) => void unsavedPaths?: Set markContentPending?: (path: string, content: string) => void onNewNotePersisted?: (path: string) => void replaceEntry?: (oldPath: string, patch: Partial & { path: string }) => void onPathRenamed?: (oldPath: string, newPath: string) => void /** Called when note loading proves the active vault path is no longer usable. */ onMissingActiveVault?: (entry: VaultEntry, error: unknown) => void | Promise /** Called after frontmatter is written to disk — used for live-reloading theme CSS vars. */ onFrontmatterContentChanged?: (path: string, content: string) => void /** Called after a frontmatter mutation is fully persisted, including follow-up renames. */ onFrontmatterPersisted?: () => void | Promise /** Called after type files or type assignments change, so derived type surfaces can reload. */ onTypeStateChanged?: () => void | Promise } function isTitleKey(key: string): boolean { return key.toLowerCase().replace(/\s+/g, '_') === 'title' } function safeString(value: unknown): string { return typeof value === 'string' ? value : '' } function entryDisplayLabel(entry: VaultEntry): string { return safeString(entry.title).trim() || safeString(entry.filename).trim() || 'Note' } type RenamedPathMap = Map function resolveLatestNotePath(renamedPaths: RenamedPathMap, path: string): string { let current = path const visited = new Set() while (!visited.has(current)) { visited.add(current) const next = renamedPaths.get(current) if (!next || next === current) return current current = next } return current } function trackRenamedNotePath(renamedPaths: RenamedPathMap, oldPath: string, newPath: string): void { if (notePathsMatch(oldPath, newPath)) return const latestPath = resolveLatestNotePath(renamedPaths, newPath) for (const [trackedOldPath, trackedNewPath] of renamedPaths) { if (trackedNewPath === oldPath) renamedPaths.set(trackedOldPath, latestPath) } renamedPaths.set(oldPath, latestPath) } interface TitleRenameDeps { vaultPath: string tabsRef: React.MutableRefObject<{ entry: VaultEntry; content: string }[]> reloadVault?: () => Promise replaceEntry?: (oldPath: string, patch: Partial & { path: string }) => void onPathRenamed?: (oldPath: string, newPath: string) => void setTabs: React.Dispatch> activeTabPathRef: React.MutableRefObject handleSwitchTab: (path: string) => void setToastMessage: (msg: string | null) => void updateTabContent: (path: string, content: string) => void } interface FrontmatterCallbackParams { config: NoteActionsConfig path: string newContent: string | undefined } function applyFrontmatterCallbacks({ config, path, newContent }: FrontmatterCallbackParams): boolean { if (!newContent) return false config.onFrontmatterContentChanged?.(path, newContent) return true } interface RenameAfterTitleChangeParams { path: string newTitle: string deps: TitleRenameDeps } async function renameAfterTitleChange({ path, newTitle, deps }: RenameAfterTitleChangeParams): Promise { const oldTitle = deps.tabsRef.current.find(t => notePathsMatch(t.entry.path, path))?.entry.title const result = await performRename({ path, newTitle, vaultPath: deps.vaultPath, oldTitle }) if (!notePathsMatch(result.new_path, path)) { const newFilename = notePathFilename(result.new_path) deps.onPathRenamed?.(path, result.new_path) deps.replaceEntry?.(path, { path: result.new_path, filename: newFilename, title: newTitle } as Partial & { path: string }) const newContent = await loadNoteContent({ path: result.new_path }) deps.setTabs(prev => prev.map(t => notePathsMatch(t.entry.path, path) ? { entry: { ...t.entry, path: result.new_path, filename: newFilename, title: newTitle }, content: newContent } : t)) if (notePathsMatch(deps.activeTabPathRef.current, path)) deps.handleSwitchTab(result.new_path) const otherTabPaths = deps.tabsRef.current .filter(t => !notePathsMatch(t.entry.path, path) && !notePathsMatch(t.entry.path, result.new_path)) .map(t => t.entry.path) await reloadTabsAfterRename({ tabPaths: otherTabPaths, updateTabContent: deps.updateTabContent }) } await reloadVaultAfterRename(deps.reloadVault) deps.setToastMessage(renameToastMessage(result.updated_files, result.failed_updates ?? 0)) } function shouldRenameOnTitleUpdate(key: string, value: FrontmatterValue): value is string { return isTitleKey(key) && typeof value === 'string' && value !== '' } function isTypeFieldKey(key: string): boolean { const normalized = key.trim().toLowerCase().replace(/\s+/g, '_') return normalized === 'type' || normalized === 'is_a' } async function notifyFrontmatterPersisted(config: NoteActionsConfig, key: string): Promise { await config.onFrontmatterPersisted?.() if (isTypeFieldKey(key)) { await config.onTypeStateChanged?.() } } interface NavigateWikilinkParams { entries: VaultEntry[] sourceEntry?: VaultEntry target: string selectNote: (entry: VaultEntry) => void } function navigateWikilink({ entries, sourceEntry, target, selectNote }: NavigateWikilinkParams): void { const found = resolveEntry(entries, target, sourceEntry) if (found) selectNote(found) else console.warn(`Navigation target not found: ${target}`) } interface MaybeRenameAfterFrontmatterUpdateParams { path: string key: string value: FrontmatterValue deps: TitleRenameDeps } async function flushBeforeNoteMutation( path: string, flushBeforeMutation?: (path: string) => Promise, ): Promise { if (!flushBeforeMutation) return true try { await flushBeforeMutation(path) return true } catch { return false } } function activePathGuardAllowsMutation( path: string, activeTabPathRef: MutableRefObject, options?: FrontmatterOpOptions, ): boolean { const requiredPath = options?.requireActivePath if (!requiredPath) return true return notePathsMatch(path, requiredPath) && notePathsMatch(activeTabPathRef.current, requiredPath) } async function maybeRenameAfterFrontmatterUpdate({ path, key, value, deps, }: MaybeRenameAfterFrontmatterUpdateParams): Promise { if (!shouldRenameOnTitleUpdate(key, value)) return try { await renameAfterTitleChange({ path, newTitle: value, deps }) } catch (err) { console.error('Failed to rename note after title change:', err) } } interface UpdateFrontmatterAndMaybeRenameParams { config: NoteActionsConfig deps: TitleRenameDeps key: string options?: FrontmatterOpOptions path: string runFrontmatterOp: RunFrontmatterOp value: FrontmatterValue } type RunFrontmatterOp = ( op: 'update' | 'delete', path: string, key: string, value?: FrontmatterValue, options?: FrontmatterOpOptions, ) => Promise async function updateFrontmatterAndMaybeRename({ config, deps, key, options, path, runFrontmatterOp, value, }: UpdateFrontmatterAndMaybeRenameParams): Promise { if (!activePathGuardAllowsMutation(path, deps.activeTabPathRef, options)) return const canFlush = await flushBeforeNoteMutation(path, config.flushBeforeNoteMutation) if (!canFlush) return if (!activePathGuardAllowsMutation(path, deps.activeTabPathRef, options)) return const newContent = await runFrontmatterOp('update', path, key, value, options) if (!applyFrontmatterCallbacks({ config, path, newContent })) return await maybeRenameAfterFrontmatterUpdate({ path, key, value, deps }) await notifyFrontmatterPersisted(config, key) } function buildTabManagementOptions( config: Pick, ) { const options: { beforeNavigate?: (fromPath: string, toPath: string) => Promise hasUnsavedChanges: (path: string) => boolean onMissingActiveVault: (entry: VaultEntry, error: unknown) => void | Promise onMissingNotePath: (entry: VaultEntry) => void onUnreadableNoteContent: (entry: VaultEntry) => void } = { hasUnsavedChanges: (path) => config.unsavedPaths?.has(path) ?? false, onMissingActiveVault: (entry, error) => { void config.onMissingActiveVault?.(entry, error) }, onMissingNotePath: (entry) => { const label = entryDisplayLabel(entry) config.setToastMessage(`"${label}" could not be opened because its file is missing or moved.`) void config.reloadVault?.() }, onUnreadableNoteContent: (entry) => { const label = entryDisplayLabel(entry) config.setToastMessage(`"${label}" could not be opened because it is not valid UTF-8 text.`) }, } if (config.flushBeforeNoteSwitch) { options.beforeNavigate = (fromPath: string) => config.flushBeforeNoteSwitch!(fromPath) } return options } function useGitignoredVisibilityTabCleanup({ activeTabPathRef, closeAllTabs, setToastMessage, }: { activeTabPathRef: React.MutableRefObject closeAllTabs: () => void setToastMessage: (msg: string | null) => void }) { useEffect(() => { if (typeof window === 'undefined') return const handleVisibilityApplied = (event: Event) => { const { hide, visiblePaths } = (event as GitignoredVisibilityAppliedEvent).detail const activePath = activeTabPathRef.current if (!hide || !activePath || visiblePaths.some((path) => notePathsMatch(path, activePath))) return closeAllTabs() setToastMessage('Closed hidden Gitignored file') } window.addEventListener(GITIGNORED_VISIBILITY_APPLIED_EVENT, handleVisibilityApplied) return () => { window.removeEventListener(GITIGNORED_VISIBILITY_APPLIED_EVENT, handleVisibilityApplied) } }, [activeTabPathRef, closeAllTabs, setToastMessage]) } function useFrontmatterActionHandlers({ config, onPathRenamed, resolvePath, renameTabsRef, setTabs, activeTabPathRef, handleSwitchTab, setToastMessage, updateTabContent, runFrontmatterOp, }: { config: NoteActionsConfig onPathRenamed?: (oldPath: string, newPath: string) => void resolvePath: (path: string) => string renameTabsRef: TitleRenameDeps['tabsRef'] setTabs: React.Dispatch> activeTabPathRef: React.MutableRefObject handleSwitchTab: (path: string) => void setToastMessage: (msg: string | null) => void updateTabContent: (path: string, newContent: string) => void runFrontmatterOp: RunFrontmatterOp }) { const handleUpdateFrontmatter = useCallback(async ( path: string, key: string, value: FrontmatterValue, options?: FrontmatterOpOptions, ) => { const currentPath = resolvePath(path) await updateFrontmatterAndMaybeRename({ config, deps: { vaultPath: config.vaultPath, tabsRef: renameTabsRef, reloadVault: config.reloadVault, replaceEntry: config.replaceEntry, onPathRenamed, setTabs, activeTabPathRef, handleSwitchTab, setToastMessage, updateTabContent, }, path: currentPath, key, value, options, runFrontmatterOp, }) }, [activeTabPathRef, config, handleSwitchTab, onPathRenamed, renameTabsRef, resolvePath, runFrontmatterOp, setTabs, setToastMessage, updateTabContent]) const handleDeleteProperty = useCallback(async (path: string, key: string, options?: FrontmatterOpOptions) => { const currentPath = resolvePath(path) if (!activePathGuardAllowsMutation(currentPath, activeTabPathRef, options)) return const canFlush = await flushBeforeNoteMutation(currentPath, config.flushBeforeNoteMutation) if (!canFlush) return if (!activePathGuardAllowsMutation(currentPath, activeTabPathRef, options)) return const newContent = await runFrontmatterOp('delete', currentPath, key, undefined, options) if (!applyFrontmatterCallbacks({ config, path: currentPath, newContent })) return await notifyFrontmatterPersisted(config, key) }, [activeTabPathRef, config, resolvePath, runFrontmatterOp]) const handleAddProperty = useCallback(async (path: string, key: string, value: FrontmatterValue, options?: FrontmatterOpOptions) => { const currentPath = resolvePath(path) if (!activePathGuardAllowsMutation(currentPath, activeTabPathRef, options)) return const canFlush = await flushBeforeNoteMutation(currentPath, config.flushBeforeNoteMutation) if (!canFlush) return if (!activePathGuardAllowsMutation(currentPath, activeTabPathRef, options)) return const newContent = await runFrontmatterOp('update', currentPath, key, value, options) if (!applyFrontmatterCallbacks({ config, path: currentPath, newContent })) return await notifyFrontmatterPersisted(config, key) }, [activeTabPathRef, config, resolvePath, runFrontmatterOp]) return { handleUpdateFrontmatter, handleDeleteProperty, handleAddProperty, } } function useFrontmatterRunner({ activeTabPathRef, entries, setToastMessage, updateEntry, updateTabContent, }: { activeTabPathRef: MutableRefObject entries: VaultEntry[] setToastMessage: NoteActionsConfig['setToastMessage'] updateEntry: NoteActionsConfig['updateEntry'] updateTabContent: (path: string, newContent: string) => void }): RunFrontmatterOp { return useCallback( (op, path, key, value, options) => runFrontmatterAndApply({ op, path, key, value, callbacks: { updateTab: updateTabContent, updateEntry, toast: setToastMessage, getEntry: (p) => findByNotePath(entries, p), shouldApply: (p) => activePathGuardAllowsMutation(p, activeTabPathRef, options), }, options, }), [activeTabPathRef, entries, setToastMessage, updateEntry, updateTabContent], ) } function useRenamedNotePathResolver(onPathRenamed?: (oldPath: string, newPath: string) => void) { const renamedPathsRef = useRef(new Map()) const handlePathRenamed = useCallback((oldPath: string, newPath: string) => { trackRenamedNotePath(renamedPathsRef.current, oldPath, newPath) onPathRenamed?.(oldPath, newPath) }, [onPathRenamed]) const resolveActionPath = useCallback((path: string) => resolveLatestNotePath(renamedPathsRef.current, path), []) return { handlePathRenamed, resolveActionPath } } export function useNoteActions(config: NoteActionsConfig) { const { entries, setToastMessage, updateEntry } = config const { handlePathRenamed, resolveActionPath } = useRenamedNotePathResolver(config.onPathRenamed) const tabMgmt = useTabManagement(buildTabManagementOptions(config)) const { setTabs, handleSelectNote, openTabWithContent, activeTabPathRef, handleSwitchTab } = tabMgmt useGitignoredVisibilityTabCleanup({ activeTabPathRef, closeAllTabs: tabMgmt.closeAllTabs, setToastMessage, }) const updateTabContent = useCallback((path: string, newContent: string) => { setTabs((prev) => prev.map((t) => notePathsMatch(t.entry.path, path) ? { ...t, content: newContent } : t)) }, [setTabs]) const creation = useNoteCreation(config, { openTabWithContent }) const rename = useNoteRename( { entries, setToastMessage, reloadVault: config.reloadVault, onPathRenamed: handlePathRenamed }, { tabs: tabMgmt.tabs, setTabs, activeTabPathRef, handleSwitchTab, updateTabContent }, ) const handleNavigateWikilink = useCallback( (target: string) => navigateWikilink({ entries, sourceEntry: tabMgmt.tabs.find((tab) => notePathsMatch(tab.entry.path, tabMgmt.activeTabPath))?.entry, target, selectNote: handleSelectNote, }), [entries, handleSelectNote, tabMgmt.activeTabPath, tabMgmt.tabs], ) const runFrontmatterOp = useFrontmatterRunner({ activeTabPathRef, entries, setToastMessage, updateEntry, updateTabContent }) const frontmatterActions = useFrontmatterActionHandlers({ config, onPathRenamed: handlePathRenamed, resolvePath: resolveActionPath, renameTabsRef: rename.tabsRef, setTabs, activeTabPathRef, handleSwitchTab, setToastMessage, updateTabContent, runFrontmatterOp, }) return { ...tabMgmt, handleNavigateWikilink, handleCreateNote: creation.handleCreateNote, handleCreateNoteImmediate: creation.handleCreateNoteImmediate, handleCreateNoteForRelationship: creation.handleCreateNoteForRelationship, handleCreateType: creation.handleCreateType, createTypeEntrySilent: creation.createTypeEntrySilent, handleUpdateFrontmatter: frontmatterActions.handleUpdateFrontmatter, handleDeleteProperty: frontmatterActions.handleDeleteProperty, handleAddProperty: frontmatterActions.handleAddProperty, handleRenameNote: rename.handleRenameNote, handleRenameFilename: rename.handleRenameFilename, handleMoveNoteToFolder: rename.handleMoveNoteToFolder, handleMoveNoteToWorkspace: rename.handleMoveNoteToWorkspace, } }