import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, type MutableRefObject } from 'react' import type { SetStateAction } from 'react' import { useSaveNote } from './useSaveNote' import { createTranslator, type AppLocale } from '../lib/i18n' import { canWritePathToVault } from '../utils/vaultPathContainment' interface Tab { entry: { path: string } content: string } interface EditorSaveConfig { updateVaultContent: (path: string, content: string) => void // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Tab types vary between layers setTabs: (fn: SetStateAction) => void setToastMessage: (msg: string | null) => void onAfterSave?: () => void /** Called after content is persisted — used to clear unsaved state and live-reload themes. */ onNotePersisted?: (path: string, content: string) => void /** Resolve stale paths (for example after a note rename) before persisting buffered content. */ resolvePath?: (path: string) => string /** Wait for an in-flight path change to settle before persisting buffered content. */ resolvePathBeforeSave?: (path: string) => Promise /** False when editor state is present but no vault is available to receive writes. */ canPersist?: boolean /** Clears pending debounced content when the persistence target changes. */ persistenceScope?: string | readonly string[] disabledSaveMessage?: string locale?: AppLocale } /** * Hook that manages editor content persistence with auto-save. * Content is auto-saved after a short idle window. Cmd+S flushes immediately. */ const noop = () => {} export const AUTO_SAVE_DEBOUNCE_MS = 1_500 export const MISSING_ACTIVE_VAULT_SAVE_MESSAGE = 'Select or restore a vault before saving.' type Translator = ReturnType interface PendingContent { path: string content: string } interface EditorSaveCommandsParams { pendingContentRef: MutableRefObject autoSaveTimerRef: MutableRefObject | null> setTabs: EditorSaveConfig['setTabs'] setToastMessage: EditorSaveConfig['setToastMessage'] saveNote: (path: string, content: string) => Promise onAfterSave: () => void onAfterSaveRef: MutableRefObject<() => void> onNotePersisted?: EditorSaveConfig['onNotePersisted'] resolvePath?: EditorSaveConfig['resolvePath'] resolvePathBeforeSave?: EditorSaveConfig['resolvePathBeforeSave'] canPersistRef: MutableRefObject persistenceScopeRef: MutableRefObject persistenceScope?: EditorSaveConfig['persistenceScope'] disabledSaveMessage: string t: Translator } function errorMessage(error: unknown): string { if (error instanceof Error) return error.message return String(error) } function isInvalidPathSaveError(message: string): boolean { const normalized = message.toLowerCase() return normalized.includes('os error 123') || normalized.includes('filename, directory name, or volume label syntax is incorrect') || normalized.includes('path is invalid on this platform') } function formatSaveFailureMessage(error: unknown, t: Translator): string { const message = errorMessage(error) if (isInvalidPathSaveError(message)) return t('save.error.invalidPath') return t('save.error.failed', { error: message }) } function useLatestValueRef(value: T): MutableRefObject { const ref = useRef(value) useLayoutEffect(() => { ref.current = value }, [value]) return ref } function resolveBufferedPath(path: string, resolvePath?: EditorSaveConfig['resolvePath']): string { return resolvePath?.(path) ?? path } async function resolvePersistPath( path: string, resolvePath?: EditorSaveConfig['resolvePath'], resolvePathBeforeSave?: EditorSaveConfig['resolvePathBeforeSave'], ): Promise { const currentPath = resolveBufferedPath(path, resolvePath) return resolvePathBeforeSave ? resolvePathBeforeSave(currentPath) : currentPath } function matchesPendingPath( pending: PendingContent | null, pathFilter?: string, resolvePath?: EditorSaveConfig['resolvePath'], ): pending is PendingContent { if (!pending) return false if (!pathFilter) return true return resolveBufferedPath(pending.path, resolvePath) === resolveBufferedPath(pathFilter, resolvePath) } function matchesPendingContent( pending: PendingContent | null, path: string, content: string, resolvePath?: EditorSaveConfig['resolvePath'], ): pending is PendingContent { return matchesPendingPath(pending, path, resolvePath) && pending.content === content } async function persistResolvedContent({ path, content, saveNote, resolvePath, resolvePathBeforeSave, persistenceScopeRef, }: { path: string content: string saveNote: (path: string, content: string) => Promise resolvePath?: EditorSaveConfig['resolvePath'] resolvePathBeforeSave?: EditorSaveConfig['resolvePathBeforeSave'] persistenceScopeRef: MutableRefObject }): Promise { const targetPath = await resolvePersistPath(path, resolvePath, resolvePathBeforeSave) if (!canWritePathToVault(targetPath, persistenceScopeRef.current ?? '')) return null await saveNote(targetPath, content) return targetPath } function applyTabContent( setTabs: EditorSaveConfig['setTabs'], path: string, content: string, ): void { setTabs((prev: Tab[]) => prev.map((t) => t.entry.path === path ? { ...t, content } : t) ) } function scheduleAutoSave({ autoSaveTimerRef, flushPending, onAfterSaveRef, setToastMessage, t, }: { autoSaveTimerRef: MutableRefObject | null> flushPending: () => Promise onAfterSaveRef: MutableRefObject<() => void> setToastMessage: EditorSaveConfig['setToastMessage'] t: Translator }): void { autoSaveTimerRef.current = setTimeout(async () => { autoSaveTimerRef.current = null try { const saved = await flushPending() if (saved) onAfterSaveRef.current() } catch (err) { console.error('Auto-save failed:', err) setToastMessage(formatSaveFailureMessage(err, t)) } }, AUTO_SAVE_DEBOUNCE_MS) } function useOnAfterSaveRef(onAfterSave: () => void) { const onAfterSaveRef = useRef(onAfterSave) useEffect(() => { onAfterSaveRef.current = onAfterSave }, [onAfterSave]) return onAfterSaveRef } function usePendingContentFlush({ pendingContentRef, saveNote, onNotePersisted, resolvePath, resolvePathBeforeSave, canPersistRef, persistenceScopeRef, }: { pendingContentRef: MutableRefObject saveNote: (path: string, content: string) => Promise onNotePersisted?: EditorSaveConfig['onNotePersisted'] resolvePath?: EditorSaveConfig['resolvePath'] resolvePathBeforeSave?: EditorSaveConfig['resolvePathBeforeSave'] canPersistRef: MutableRefObject persistenceScopeRef: MutableRefObject }) { return useCallback(async (pathFilter?: string): Promise => { const pending = pendingContentRef.current if (!matchesPendingPath(pending, pathFilter, resolvePath)) return false if (!canPersistRef.current) return false const { path, content } = pending const targetPath = await persistResolvedContent({ path, content, saveNote, resolvePath, resolvePathBeforeSave, persistenceScopeRef, }) if (targetPath === null) { if (pendingContentRef.current === pending) pendingContentRef.current = null return false } if (!matchesPendingContent(pendingContentRef.current, targetPath, content, resolvePath)) { return false } pendingContentRef.current = null onNotePersisted?.(targetPath, content) return true }, [canPersistRef, onNotePersisted, pendingContentRef, persistenceScopeRef, resolvePath, resolvePathBeforeSave, saveNote]) } function useCancelAutoSave(autoSaveTimerRef: MutableRefObject | null>) { const cancelAutoSave = useCallback(() => { if (!autoSaveTimerRef.current) return clearTimeout(autoSaveTimerRef.current) autoSaveTimerRef.current = null }, [autoSaveTimerRef]) useEffect(() => () => cancelAutoSave(), [cancelAutoSave]) return cancelAutoSave } function usePendingContentScopeReset({ cancelAutoSave, pendingContentRef, persistenceScope, }: { cancelAutoSave: () => void pendingContentRef: MutableRefObject persistenceScope?: string | readonly string[] }) { const previousScopeRef = useRef(persistenceScope) useLayoutEffect(() => { if (previousScopeRef.current === persistenceScope) return previousScopeRef.current = persistenceScope pendingContentRef.current = null cancelAutoSave() }, [cancelAutoSave, pendingContentRef, persistenceScope]) } async function persistUnsavedFallback({ unsavedFallback, saveNote, onNotePersisted, resolvePath, resolvePathBeforeSave, persistenceScopeRef, }: { unsavedFallback?: { path: string; content: string } saveNote: (path: string, content: string) => Promise onNotePersisted?: EditorSaveConfig['onNotePersisted'] resolvePath?: EditorSaveConfig['resolvePath'] resolvePathBeforeSave?: EditorSaveConfig['resolvePathBeforeSave'] persistenceScopeRef: MutableRefObject }): Promise { if (!unsavedFallback) return false const targetPath = await persistResolvedContent({ path: unsavedFallback.path, content: unsavedFallback.content, saveNote, resolvePath, resolvePathBeforeSave, persistenceScopeRef, }) if (targetPath === null) return false onNotePersisted?.(targetPath, unsavedFallback.content) return true } function pausedSaveResult({ canPersistRef, pendingContentRef, unsavedFallback, setToastMessage, disabledSaveMessage, t, }: { canPersistRef: MutableRefObject pendingContentRef: MutableRefObject unsavedFallback?: { path: string; content: string } setToastMessage: EditorSaveConfig['setToastMessage'] disabledSaveMessage: string t: Translator }): boolean | null { if (canPersistRef.current) return null const hasUnsavedContent = pendingContentRef.current !== null || unsavedFallback !== undefined setToastMessage(hasUnsavedContent ? disabledSaveMessage : t('save.toast.nothingToSave')) return !hasUnsavedContent } async function persistImmediateSave({ unsavedFallback, flushPending, saveNote, onNotePersisted, resolvePath, resolvePathBeforeSave, persistenceScopeRef, setToastMessage, onAfterSave, t, }: { unsavedFallback?: { path: string; content: string } flushPending: (pathFilter?: string) => Promise saveNote: (path: string, content: string) => Promise onNotePersisted?: EditorSaveConfig['onNotePersisted'] resolvePath?: EditorSaveConfig['resolvePath'] resolvePathBeforeSave?: EditorSaveConfig['resolvePathBeforeSave'] persistenceScopeRef: MutableRefObject setToastMessage: EditorSaveConfig['setToastMessage'] onAfterSave: () => void t: Translator }): Promise { try { const saved = await flushPending() const savedFallback = !saved && await persistUnsavedFallback({ unsavedFallback, saveNote, onNotePersisted, resolvePath, resolvePathBeforeSave, persistenceScopeRef, }) setToastMessage(saved || savedFallback ? t('save.toast.saved') : t('save.toast.nothingToSave')) onAfterSave() return true } catch (err) { console.error('Save failed:', err) setToastMessage(formatSaveFailureMessage(err, t)) return false } } function useImmediateSaveCommands({ pendingContentRef, cancelAutoSave, flushPending, setToastMessage, onAfterSave, saveNote, onNotePersisted, resolvePath, resolvePathBeforeSave, persistenceScopeRef, canPersistRef, disabledSaveMessage, t, }: { pendingContentRef: MutableRefObject cancelAutoSave: () => void flushPending: (pathFilter?: string) => Promise setToastMessage: EditorSaveConfig['setToastMessage'] onAfterSave: () => void saveNote: (path: string, content: string) => Promise onNotePersisted?: EditorSaveConfig['onNotePersisted'] resolvePath?: EditorSaveConfig['resolvePath'] resolvePathBeforeSave?: EditorSaveConfig['resolvePathBeforeSave'] persistenceScopeRef: MutableRefObject canPersistRef: MutableRefObject disabledSaveMessage: string t: Translator }) { const handleSave = useCallback(async (unsavedFallback?: { path: string; content: string }): Promise => { cancelAutoSave() const pausedResult = pausedSaveResult({ canPersistRef, pendingContentRef, unsavedFallback, setToastMessage, disabledSaveMessage, t, }) if (pausedResult !== null) return pausedResult return persistImmediateSave({ unsavedFallback, flushPending, saveNote, onNotePersisted, resolvePath, resolvePathBeforeSave, persistenceScopeRef, setToastMessage, onAfterSave, t, }) }, [canPersistRef, cancelAutoSave, disabledSaveMessage, flushPending, onAfterSave, onNotePersisted, pendingContentRef, persistenceScopeRef, resolvePath, resolvePathBeforeSave, saveNote, setToastMessage, t]) const savePendingForPath = useCallback( (path: string): Promise => { cancelAutoSave() return canPersistRef.current ? flushPending(path) : Promise.resolve(false) }, [canPersistRef, cancelAutoSave, flushPending], ) const savePending = useCallback((): Promise => { cancelAutoSave() return canPersistRef.current ? flushPending() : Promise.resolve(false) }, [canPersistRef, cancelAutoSave, flushPending]) return { handleSave, savePendingForPath, savePending } } function useContentChangeCommand({ pendingContentRef, autoSaveTimerRef, setTabs, setToastMessage, cancelAutoSave, flushPending, onAfterSaveRef, canPersistRef, persistenceScopeRef, resolvePath, t, }: { pendingContentRef: MutableRefObject autoSaveTimerRef: MutableRefObject | null> setTabs: EditorSaveConfig['setTabs'] setToastMessage: EditorSaveConfig['setToastMessage'] cancelAutoSave: () => void flushPending: () => Promise onAfterSaveRef: MutableRefObject<() => void> canPersistRef: MutableRefObject persistenceScopeRef: MutableRefObject resolvePath?: EditorSaveConfig['resolvePath'] t: Translator }) { return useCallback((path: string, content: string) => { const currentPath = resolveBufferedPath(path, resolvePath) if (!canWritePathToVault(currentPath, persistenceScopeRef.current ?? '')) return pendingContentRef.current = { path: currentPath, content } applyTabContent(setTabs, currentPath, content) cancelAutoSave() if (!canPersistRef.current) return scheduleAutoSave({ autoSaveTimerRef, flushPending, onAfterSaveRef, setToastMessage, t }) }, [autoSaveTimerRef, canPersistRef, cancelAutoSave, flushPending, onAfterSaveRef, pendingContentRef, persistenceScopeRef, resolvePath, setTabs, setToastMessage, t]) } function useEditorSaveCommands({ pendingContentRef, autoSaveTimerRef, setTabs, setToastMessage, saveNote, onAfterSave, onAfterSaveRef, onNotePersisted, resolvePath, resolvePathBeforeSave, canPersistRef, persistenceScopeRef, persistenceScope, disabledSaveMessage, t, }: EditorSaveCommandsParams) { const flushPending = usePendingContentFlush({ pendingContentRef, saveNote, onNotePersisted, resolvePath, resolvePathBeforeSave, canPersistRef, persistenceScopeRef, }) const cancelAutoSave = useCancelAutoSave(autoSaveTimerRef) usePendingContentScopeReset({ cancelAutoSave, pendingContentRef, persistenceScope }) const { handleSave, savePendingForPath, savePending } = useImmediateSaveCommands({ pendingContentRef, cancelAutoSave, flushPending, setToastMessage, onAfterSave, saveNote, onNotePersisted, resolvePath, resolvePathBeforeSave, persistenceScopeRef, canPersistRef, disabledSaveMessage, t, }) const handleContentChange = useContentChangeCommand({ pendingContentRef, autoSaveTimerRef, setTabs, setToastMessage, cancelAutoSave, flushPending: () => flushPending(), onAfterSaveRef, canPersistRef, persistenceScopeRef, resolvePath, t, }) return { handleSave, handleContentChange, savePendingForPath, savePending } } export function useEditorSave({ updateVaultContent, setTabs, setToastMessage, onAfterSave = noop, onNotePersisted, resolvePath, resolvePathBeforeSave, canPersist = true, persistenceScope, disabledSaveMessage, locale = 'en', }: EditorSaveConfig) { const pendingContentRef = useRef<{ path: string; content: string } | null>(null) const autoSaveTimerRef = useRef | null>(null) const canPersistRef = useLatestValueRef(canPersist) const persistenceScopeRef = useLatestValueRef(persistenceScope) const t = useMemo(() => createTranslator(locale), [locale]) const disabledSaveText = disabledSaveMessage ?? t('save.toast.missingActiveVault') const updateTabAndContent = useCallback((path: string, content: string) => { if ( pendingContentRef.current && !matchesPendingContent(pendingContentRef.current, path, content, resolvePath) ) { return } updateVaultContent(path, content) applyTabContent(setTabs, path, content) }, [pendingContentRef, resolvePath, updateVaultContent, setTabs]) const { saveNote } = useSaveNote(updateTabAndContent) const onAfterSaveRef = useOnAfterSaveRef(onAfterSave) return useEditorSaveCommands({ pendingContentRef, autoSaveTimerRef, setTabs, setToastMessage, saveNote, onAfterSave, onAfterSaveRef, onNotePersisted, resolvePath, resolvePathBeforeSave, canPersistRef, persistenceScopeRef, persistenceScope, disabledSaveMessage: disabledSaveText, t, }) }