"use client"; import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; import { fetchUserSettings } from "@/lib/api/domains/settings-api"; import { fetchGitHubWorkspaceSettings, updateGitHubWorkspaceSettings, } from "@/lib/api/domains/github-api"; import { createQueuedUserSettingsSync } from "@/lib/user-settings-sync"; import { readSavedPresets, setSavedPresetDefault, type SavedPreset, type SavedPresetKind, } from "./saved-preset-model"; export type { SavedPreset } from "./saved-preset-model"; const listeners = new Set<() => void>(); let snapshot: SavedPreset[] = []; let snapshotVersion = 0; const emptySnapshot: SavedPreset[] = []; function getSnapshot(): SavedPreset[] { return snapshot; } function getServerSnapshot(): SavedPreset[] { return emptySnapshot; } function subscribe(cb: () => void) { listeners.add(cb); return () => { listeners.delete(cb); }; } function publish(next: SavedPreset[]) { snapshot = next; snapshotVersion += 1; for (const l of listeners) l(); } const syncServer = createQueuedUserSettingsSync((next) => ({ github_saved_presets: next, })); // Portable presets use a module snapshot shared by every hook instance, so queue the // complete read/persist/publish mutation rather than only its already-built payload. let portableMutationQueue = Promise.resolve(); let portableMutationGeneration = 0; function queuePortableMutation(mutation: () => Promise): Promise { const generation = portableMutationGeneration; const queued = portableMutationQueue .catch(() => undefined) .then(() => { if (generation !== portableMutationGeneration) return; return mutation(); }); portableMutationQueue = queued; return queued; } // Shared across hook instances so writes for the same workspace stay ordered. // Separate workspaces remain independent during navigation between them. const workspaceSyncQueues = new Map>(); function syncWorkspaceSavedPresets(workspaceId: string, next: SavedPreset[]): Promise { const previous = workspaceSyncQueues.get(workspaceId) ?? Promise.resolve(); const queued = previous .catch(() => undefined) .then(() => updateGitHubWorkspaceSettings({ workspace_id: workspaceId, saved_presets: next, }).then(() => undefined), ); workspaceSyncQueues.set(workspaceId, queued); void queued .finally(() => { if (workspaceSyncQueues.get(workspaceId) === queued) workspaceSyncQueues.delete(workspaceId); }) .catch(() => undefined); return queued; } export function __resetSnapshotForTests() { if (process.env.NODE_ENV === "production") { throw new Error("__resetSnapshotForTests must not be called in production"); } snapshot = []; snapshotVersion = 0; portableMutationGeneration += 1; portableMutationQueue = Promise.resolve(); workspaceSyncQueues.clear(); for (const l of listeners) l(); } function useUserSavedPresetsSync(enabled: boolean) { const [loadState, setLoadState] = useState({ loading: true, error: false }); const [attempt, setAttempt] = useState(0); const retry = useCallback(() => setAttempt((value) => value + 1), []); useEffect(() => { if (!enabled) return; let cancelled = false; const initialVersion = snapshotVersion; setLoadState({ loading: true, error: false }); fetchUserSettings({ cache: "no-store" }) .then((response) => { const serverPresets = readSavedPresets(response.settings.github_saved_presets); if (cancelled) return; if (snapshotVersion === initialVersion) publish(serverPresets); setLoadState({ loading: false, error: false }); }) .catch(() => { if (!cancelled) setLoadState({ loading: false, error: snapshotVersion === initialVersion }); }); return () => { cancelled = true; }; }, [enabled, attempt]); return { ...loadState, retry }; } function useWorkspaceSavedPresets(workspaceId: string | null) { const [state, setState] = useState<{ workspaceId: string | null; presets?: SavedPreset[]; loading: boolean; error: boolean; }>({ workspaceId, loading: true, error: false }); const [attempt, setAttempt] = useState(0); const retry = useCallback(() => setAttempt((value) => value + 1), []); const writeSeq = useRef(0); useEffect(() => { if (!workspaceId) { setState({ workspaceId, loading: true, error: false }); return; } let cancelled = false; const seq = writeSeq.current; setState({ workspaceId, loading: true, error: false }); fetchGitHubWorkspaceSettings(workspaceId) .then((settings) => { if (cancelled || seq !== writeSeq.current) return; const serverPresets = readSavedPresets(settings.saved_presets); setState({ workspaceId, presets: serverPresets, loading: false, error: false }); }) .catch(() => { if (!cancelled && seq === writeSeq.current) { setState({ workspaceId, loading: false, error: true }); } }); return () => { cancelled = true; }; }, [workspaceId, attempt]); const setWorkspacePresetsFromLocal = useCallback( (next: SavedPreset[]) => { writeSeq.current += 1; setState({ workspaceId, presets: next, loading: false, error: false }); }, [workspaceId], ); const current = state.workspaceId === workspaceId; return { workspacePresets: current ? state.presets : undefined, setWorkspacePresets: setWorkspacePresetsFromLocal, loading: current ? state.loading : true, error: current ? state.error : false, retry, }; } function discardSavedPreset(presets: SavedPreset[], id: string): SavedPreset[] { return presets.filter((preset) => preset.id !== id); } function restoreSavedPreset( presets: SavedPreset[], preset: SavedPreset, originalIndex: number, ): SavedPreset[] { if (presets.some((candidate) => candidate.id === preset.id)) return presets; const restored = preset.isDefault && presets.some((candidate) => candidate.kind === preset.kind && candidate.isDefault) ? { ...preset, isDefault: false } : preset; // Best-effort: the insertion point may shift if concurrent saves ran before rollback. const insertionIndex = Math.min(originalIndex, presets.length); return [...presets.slice(0, insertionIndex), restored, ...presets.slice(insertionIndex)]; } type SavedPresetMutationContext = { workspaceId: string | null; presets: SavedPreset[]; portableGeneration: number; }; function readMutationPresets(context: SavedPresetMutationContext): SavedPreset[] { return context.workspaceId === null ? snapshot : context.presets; } function persistSavedPresets( context: SavedPresetMutationContext, next: SavedPreset[], ): Promise { if (context.workspaceId !== null) { return syncWorkspaceSavedPresets(context.workspaceId, next); } return syncServer(next); } function useSavedPresetMutationContext( workspaceId: string | null, activePresets: SavedPreset[], setWorkspacePresets: (next: SavedPreset[]) => void, ) { const mutationContextRef = useRef({ workspaceId, presets: activePresets, portableGeneration: portableMutationGeneration, }); if ( mutationContextRef.current.workspaceId !== workspaceId || mutationContextRef.current.portableGeneration !== portableMutationGeneration ) { mutationContextRef.current = { workspaceId, presets: activePresets, portableGeneration: portableMutationGeneration, }; } else { mutationContextRef.current.presets = activePresets; } const applyLocal = useCallback( (context: SavedPresetMutationContext, next: SavedPreset[]) => { if ( context.workspaceId === null && context.portableGeneration !== portableMutationGeneration ) { return; } context.presets = next; if (mutationContextRef.current !== context) return; if (context.workspaceId !== null) setWorkspacePresets(next); else publish(next); }, [setWorkspacePresets], ); return { mutationContextRef, applyLocal }; } function useSavedPresetMutationQueue(workspaceId: string | null) { const mutationQueueRef = useRef>(Promise.resolve()); const previousWorkspaceIdRef = useRef(workspaceId); if (previousWorkspaceIdRef.current !== workspaceId) { previousWorkspaceIdRef.current = workspaceId; mutationQueueRef.current = Promise.resolve(); } return useCallback( (mutation: () => Promise) => { if (workspaceId === null) return queuePortableMutation(mutation); const queued = mutationQueueRef.current.catch(() => undefined).then(mutation); mutationQueueRef.current = queued; return queued; }, [workspaceId], ); } export function useSavedPresets(workspaceId: string | null = null) { const presets = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); const { workspacePresets, setWorkspacePresets, ...workspaceLoad } = useWorkspaceSavedPresets(workspaceId); const workspacePresetsRef = useRef(workspacePresets); workspacePresetsRef.current = workspacePresets; const userLoad = useUserSavedPresetsSync(!workspaceId); const activePresets = workspaceId ? (workspacePresets ?? emptySnapshot) : presets; const { mutationContextRef, applyLocal } = useSavedPresetMutationContext( workspaceId, activePresets, setWorkspacePresets, ); const queueMutation = useSavedPresetMutationQueue(workspaceId); /** * @returns The created preset, or null while workspace settings are unavailable. * @throws The persistence error after rolling back the optimistic update. */ const save = useCallback( async (input: Omit) => { if (workspaceId && workspacePresetsRef.current === undefined) return null; const context = mutationContextRef.current; const preset: SavedPreset = { ...input, id: `p_${crypto.randomUUID()}`, createdAt: new Date().toISOString(), isDefault: false, }; const append = (current: SavedPreset[]) => current.some((saved) => saved.id === preset.id) ? current : [...current, preset]; applyLocal(context, append(readMutationPresets(context))); try { await queueMutation(async () => { await persistSavedPresets(context, append(readMutationPresets(context))); }); return preset; } catch (error) { const current = readMutationPresets(context); const rolledBack = discardSavedPreset(current, preset.id); if (rolledBack.length !== current.length) applyLocal(context, rolledBack); throw error; } }, [applyLocal, queueMutation, workspaceId], ); /** * @returns Whether an existing preset was removed. * @throws The persistence error; the removed preset is restored before rejection. */ const remove = useCallback( async (id: string) => { if (workspaceId && workspacePresetsRef.current === undefined) return false; const context = mutationContextRef.current; const current = readMutationPresets(context); const originalIndex = current.findIndex((preset) => preset.id === id); if (originalIndex === -1) return false; const removedPreset = current[originalIndex]; applyLocal(context, discardSavedPreset(current, id)); try { await queueMutation(async () => { // Re-read the scoped context so concurrent saves appended after the optimistic remove // are included in the persisted payload; the removed id is already absent. await persistSavedPresets(context, discardSavedPreset(readMutationPresets(context), id)); }); return true; } catch (error) { const latest = readMutationPresets(context); const rolledBack = restoreSavedPreset(latest, removedPreset, originalIndex); if (rolledBack !== latest) applyLocal(context, rolledBack); throw error; } }, [applyLocal, queueMutation, workspaceId], ); /** * @returns Whether a changed default was persisted; loading and unchanged targets return false. * @remarks Queued same-kind calls run in invocation order, so the last changed target wins. * @throws The persistence error when the settings update fails. */ const setDefault = useCallback( async (kind: SavedPresetKind, id: string | null) => { if (workspaceId && workspacePresetsRef.current === undefined) return false; const context = mutationContextRef.current; let persisted = false; await queueMutation(async () => { const current = readMutationPresets(context); const next = setSavedPresetDefault(current, kind, id); if (next === current) { // No-op when already matched or the target was removed while queued. return; } await persistSavedPresets(context, next); persisted = true; // `setDefault` publishes only after persistence succeeds (no optimistic update). // Re-read the scoped context so that concurrent mutations applied // during the await (e.g. sibling-hook writes for portable user settings) // are merged in before publishing the new default state. // This is also a no-op when a concurrent remove deleted the target: // setSavedPresetDefault returns the same reference for a missing id. const latest = readMutationPresets(context); const remerged = setSavedPresetDefault(latest, kind, id); if (remerged !== latest) applyLocal(context, remerged); }); return persisted; }, [applyLocal, queueMutation, workspaceId], ); return { presets: activePresets, save, remove, setDefault, ...(workspaceId ? workspaceLoad : userLoad), }; }