Files
tolaria/src/hooks/useEditorSave.ts
2026-05-08 01:12:39 +02:00

569 lines
18 KiB
TypeScript

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<any[]>) => 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<string>
/** 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<typeof createTranslator>
interface PendingContent {
path: string
content: string
}
interface EditorSaveCommandsParams {
pendingContentRef: MutableRefObject<PendingContent | null>
autoSaveTimerRef: MutableRefObject<ReturnType<typeof setTimeout> | null>
setTabs: EditorSaveConfig['setTabs']
setToastMessage: EditorSaveConfig['setToastMessage']
saveNote: (path: string, content: string) => Promise<void>
onAfterSave: () => void
onAfterSaveRef: MutableRefObject<() => void>
onNotePersisted?: EditorSaveConfig['onNotePersisted']
resolvePath?: EditorSaveConfig['resolvePath']
resolvePathBeforeSave?: EditorSaveConfig['resolvePathBeforeSave']
canPersistRef: MutableRefObject<boolean>
persistenceScopeRef: MutableRefObject<string | readonly string[] | undefined>
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<T>(value: T): MutableRefObject<T> {
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<string> {
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<void>
resolvePath?: EditorSaveConfig['resolvePath']
resolvePathBeforeSave?: EditorSaveConfig['resolvePathBeforeSave']
persistenceScopeRef: MutableRefObject<string | readonly string[] | undefined>
}): Promise<string | null> {
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<ReturnType<typeof setTimeout> | null>
flushPending: () => Promise<boolean>
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<PendingContent | null>
saveNote: (path: string, content: string) => Promise<void>
onNotePersisted?: EditorSaveConfig['onNotePersisted']
resolvePath?: EditorSaveConfig['resolvePath']
resolvePathBeforeSave?: EditorSaveConfig['resolvePathBeforeSave']
canPersistRef: MutableRefObject<boolean>
persistenceScopeRef: MutableRefObject<string | readonly string[] | undefined>
}) {
return useCallback(async (pathFilter?: string): Promise<boolean> => {
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<ReturnType<typeof setTimeout> | 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<PendingContent | null>
persistenceScope?: 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<void>
onNotePersisted?: EditorSaveConfig['onNotePersisted']
resolvePath?: EditorSaveConfig['resolvePath']
resolvePathBeforeSave?: EditorSaveConfig['resolvePathBeforeSave']
persistenceScopeRef: MutableRefObject<string | readonly string[] | undefined>
}): Promise<boolean> {
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<boolean>
pendingContentRef: MutableRefObject<PendingContent | null>
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<boolean>
saveNote: (path: string, content: string) => Promise<void>
onNotePersisted?: EditorSaveConfig['onNotePersisted']
resolvePath?: EditorSaveConfig['resolvePath']
resolvePathBeforeSave?: EditorSaveConfig['resolvePathBeforeSave']
persistenceScopeRef: MutableRefObject<string | readonly string[] | undefined>
setToastMessage: EditorSaveConfig['setToastMessage']
onAfterSave: () => void
t: Translator
}): Promise<boolean> {
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<PendingContent | null>
cancelAutoSave: () => void
flushPending: (pathFilter?: string) => Promise<boolean>
setToastMessage: EditorSaveConfig['setToastMessage']
onAfterSave: () => void
saveNote: (path: string, content: string) => Promise<void>
onNotePersisted?: EditorSaveConfig['onNotePersisted']
resolvePath?: EditorSaveConfig['resolvePath']
resolvePathBeforeSave?: EditorSaveConfig['resolvePathBeforeSave']
persistenceScopeRef: MutableRefObject<string | readonly string[] | undefined>
canPersistRef: MutableRefObject<boolean>
disabledSaveMessage: string
t: Translator
}) {
const handleSave = useCallback(async (unsavedFallback?: { path: string; content: string }): Promise<boolean> => {
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<boolean> => {
cancelAutoSave()
return canPersistRef.current ? flushPending(path) : Promise.resolve(false)
},
[canPersistRef, cancelAutoSave, flushPending],
)
const savePending = useCallback((): Promise<boolean> => {
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<PendingContent | null>
autoSaveTimerRef: MutableRefObject<ReturnType<typeof setTimeout> | null>
setTabs: EditorSaveConfig['setTabs']
setToastMessage: EditorSaveConfig['setToastMessage']
cancelAutoSave: () => void
flushPending: () => Promise<boolean>
onAfterSaveRef: MutableRefObject<() => void>
canPersistRef: MutableRefObject<boolean>
persistenceScopeRef: MutableRefObject<string | readonly string[] | undefined>
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<ReturnType<typeof setTimeout> | 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,
})
}