import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { buildEditHistoryEntry, createEmptyEditHistory, hashEditHistoryContent, pushEditHistoryEntry, redoEditHistory, undoEditHistory, type BuildEditHistoryEntryInput, type EditHistoryEntry, type EditHistoryKind, type EditHistoryState, type EditHistoryTransitionResult, } from "../utils/editHistory"; import { createIndexedDbEditHistoryStorage, loadEditHistoryState, saveEditHistoryState, type EditHistoryStorageAdapter, } from "../utils/editHistoryStorage"; interface RecordEditInput { label: string; kind: EditHistoryKind; coalesceKey?: string; coalesceMs?: number; files: BuildEditHistoryEntryInput["files"]; } interface ApplyCallbacks { readFile: (path: string) => Promise; writeFile: (path: string, content: string) => Promise; } interface UsePersistentEditHistoryOptions { projectId: string | null; storage?: EditHistoryStorageAdapter; now?: () => number; } /** * Per-file content the restore just applied. `restored` is the bytes written to * disk (the undo/redo target); `previous` is what was on disk immediately before * (the current live preview state). The undo preview-sync diffs these to decide * whether the restore is soft-reloadable (attributes/style/GSAP-script only) or * needs a full iframe reload. */ interface ApplyRestoredFile { previous: string; restored: string; } interface ApplyResult { ok: boolean; reason?: "empty" | "content-mismatch"; label?: string; paths?: string[]; files?: Record; } interface PersistentEditHistoryStoreOptions { projectId: string; storage: EditHistoryStorageAdapter; initialState: EditHistoryState; now?: () => number; onChange: (state: EditHistoryState) => void; } type EditHistoryMutation = (state: EditHistoryState) => Promise<{ state: EditHistoryState; result: T; }>; /** Pair the just-written (`restored`) bytes with the pre-write (`previous`) bytes per path. */ function restoredFilesMap( filesToWrite: Record, currentFiles: Record, ): Record { const out: Record = {}; for (const [path, restored] of Object.entries(filesToWrite)) { out[path] = { previous: currentFiles[path] ?? "", restored }; } return out; } function createEntryId(now: number): string { return `edit-${now.toString(36)}-${Math.random().toString(36).slice(2, 10)}`; } function snapshotEditHistoryState(state: EditHistoryState) { const undoEntry = state.undo[state.undo.length - 1] ?? null; const redoEntry = state.redo[state.redo.length - 1] ?? null; return { canUndo: Boolean(undoEntry), canRedo: Boolean(redoEntry), undoLabel: undoEntry?.label ?? null, redoLabel: redoEntry?.label ?? null, undoPaths: undoEntry ? Object.keys(undoEntry.files) : [], redoPaths: redoEntry ? Object.keys(redoEntry.files) : [], state, }; } async function readCurrentFileHashes( paths: string[], readFile: (path: string) => Promise, ): Promise<{ currentFiles: Record; currentHashes: Record; }> { const currentFiles: Record = {}; const currentHashes: Record = {}; for (const path of paths) { const content = await readFile(path); currentFiles[path] = content; currentHashes[path] = hashEditHistoryContent(content); } return { currentFiles, currentHashes }; } async function writeFilesWithRollback({ files, rollbackFiles, writeFile, }: { files: Record; rollbackFiles: Record; writeFile: (path: string, content: string) => Promise; }): Promise { const writtenPaths: string[] = []; try { for (const [path, content] of Object.entries(files)) { await writeFile(path, content); writtenPaths.push(path); } } catch (error) { try { for (const path of writtenPaths.reverse()) { await writeFile(path, rollbackFiles[path]); } } catch (rollbackError) { throw new AggregateError( [error, rollbackError], "Failed to apply edit history and rollback did not complete", ); } throw error; } } /** * Apply one undo/redo step: read current on-disk hashes, run the direction's * transition, write the restored files with rollback, and shape the ApplyResult. * `entry` is the stack top used to know which paths to hash before applying. */ async function applyHistoryStep( currentState: EditHistoryState, entry: EditHistoryEntry | undefined, transition: ( state: EditHistoryState, currentHashes: Record, now: number, ) => EditHistoryTransitionResult, now: () => number, callbacks: ApplyCallbacks, ): Promise<{ state: EditHistoryState; result: ApplyResult }> { if (!entry) { return { state: currentState, result: { ok: false, reason: "empty" } }; } const { currentFiles, currentHashes } = await readCurrentFileHashes( Object.keys(entry.files), callbacks.readFile, ); const result = transition(currentState, currentHashes, now()); if (!result.ok) { return { state: currentState, result: { ok: false, reason: result.reason }, }; } await writeFilesWithRollback({ files: result.filesToWrite, rollbackFiles: currentFiles, writeFile: callbacks.writeFile, }); return { state: result.state, result: { ok: true, label: result.entry.label, paths: Object.keys(result.entry.files), files: restoredFilesMap(result.filesToWrite, currentFiles), }, }; } export function createPersistentEditHistoryStore({ projectId, storage, initialState, now = Date.now, onChange, }: PersistentEditHistoryStoreOptions) { let state = initialState; let queue = Promise.resolve(); const save = async (nextState: EditHistoryState) => { state = nextState; onChange(nextState); try { await saveEditHistoryState(storage, projectId, nextState); } catch { // Keep in-memory history usable when IndexedDB is unavailable. } }; const mutate = async (mutation: EditHistoryMutation): Promise => { const run = queue.then(async () => { const { state: nextState, result } = await mutation(state); if (nextState !== state) await save(nextState); return result; }); queue = run.then( () => undefined, () => undefined, ); return run; }; return { snapshot: () => snapshotEditHistoryState(state), async recordEdit(input: RecordEditInput) { await mutate(async (currentState) => { const timestamp = now(); const entry = buildEditHistoryEntry({ ...input, id: createEntryId(timestamp), projectId, now: timestamp, }); return { state: pushEditHistoryEntry(currentState, entry), result: undefined, }; }); }, async undo(callbacks: ApplyCallbacks): Promise { return mutate((currentState) => applyHistoryStep( currentState, currentState.undo[currentState.undo.length - 1], undoEditHistory, now, callbacks, ), ); }, async redo(callbacks: ApplyCallbacks): Promise { return mutate((currentState) => applyHistoryStep( currentState, currentState.redo[currentState.redo.length - 1], redoEditHistory, now, callbacks, ), ); }, }; } export async function createPersistentEditHistoryController({ projectId, storage, now = Date.now, onChange, }: { projectId: string; storage: EditHistoryStorageAdapter; now?: () => number; onChange: (state: EditHistoryState) => void; }) { let state = await loadEditHistoryState(storage, projectId); const store = createPersistentEditHistoryStore({ projectId, storage, initialState: state, now, onChange: (nextState) => { state = nextState; onChange(nextState); }, }); return store; } export function usePersistentEditHistory(options: UsePersistentEditHistoryOptions) { const storage = useMemo( () => options.storage ?? createIndexedDbEditHistoryStorage(), [options.storage], ); const now = options.now ?? Date.now; const [state, setState] = useState(() => createEmptyEditHistory()); const [loaded, setLoaded] = useState(false); const projectId = options.projectId; const storeRef = useRef | null>(null); useEffect(() => { let cancelled = false; const emptyState = createEmptyEditHistory(); storeRef.current = null; setState(emptyState); setLoaded(false); if (!projectId) { setLoaded(true); return; } loadEditHistoryState(storage, projectId) .then((loadedState) => { if (cancelled) return; storeRef.current = createPersistentEditHistoryStore({ projectId, storage, initialState: loadedState, now, onChange: setState, }); setState(loadedState); }) .catch(() => { if (cancelled) return; storeRef.current = createPersistentEditHistoryStore({ projectId, storage, initialState: emptyState, now, onChange: setState, }); setState(emptyState); }) .finally(() => { if (!cancelled) setLoaded(true); }); return () => { cancelled = true; }; }, [now, projectId, storage]); const recordEdit = useCallback(async (input: RecordEditInput) => { await storeRef.current?.recordEdit(input); }, []); const undo = useCallback(async (callbacks: ApplyCallbacks): Promise => { return storeRef.current?.undo(callbacks) ?? { ok: false, reason: "empty" }; }, []); const redo = useCallback(async (callbacks: ApplyCallbacks): Promise => { return storeRef.current?.redo(callbacks) ?? { ok: false, reason: "empty" }; }, []); return { loaded, ...snapshotEditHistoryState(state), recordEdit, undo, redo, }; }