394 lines
14 KiB
TypeScript
394 lines
14 KiB
TypeScript
"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<SavedPreset[]>((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<void>): Promise<void> {
|
|
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<string, Promise<void>>();
|
|
|
|
function syncWorkspaceSavedPresets(workspaceId: string, next: SavedPreset[]): Promise<void> {
|
|
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<void> {
|
|
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<SavedPresetMutationContext>({
|
|
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<void>>(Promise.resolve());
|
|
const previousWorkspaceIdRef = useRef(workspaceId);
|
|
if (previousWorkspaceIdRef.current !== workspaceId) {
|
|
previousWorkspaceIdRef.current = workspaceId;
|
|
mutationQueueRef.current = Promise.resolve();
|
|
}
|
|
|
|
return useCallback(
|
|
(mutation: () => Promise<void>) => {
|
|
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<SavedPreset, "id" | "createdAt" | "isDefault">) => {
|
|
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),
|
|
};
|
|
}
|