Files
2026-09-12 10:06:05 +02:00

273 lines
8.3 KiB
TypeScript

"use client";
import { useCallback, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useToast } from "@/components/toast-provider";
import type { PresetOption } from "./search-bar";
import type { SidebarSelection } from "./presets-sidebar";
import type { SavedPreset } from "./saved-preset-model";
import type { useSavedPresets } from "./use-saved-presets";
type SavedPresetStore = ReturnType<typeof useSavedPresets>;
type WorkspaceId = string | null;
type IsCurrentWorkspace = (workspaceId: WorkspaceId) => boolean;
type PendingDefaultMutation = {
presetId: string;
};
type SavedPresetActionsOptions = {
workspaceId: string | null;
selection: SidebarSelection;
customQuery: string;
resolvedPrPresets: PresetOption[];
resolvedIssuePresets: PresetOption[];
setProgrammaticSelection: (selection: SidebarSelection) => void;
setQueryImmediate: (query: string) => void;
setRepoFilter: (repo: string) => void;
savedPresetStore: SavedPresetStore;
markSearchInteracted: () => void;
};
function firstPresetSelection(
kind: SidebarSelection["kind"],
pr: PresetOption[],
issue: PresetOption[],
) {
const preset = (kind === "pr" ? pr : issue)[0];
return {
selection: { kind, source: "preset", id: preset?.value ?? "" } as SidebarSelection,
filter: preset?.filter ?? "",
};
}
function useIsCurrentWorkspace(workspaceId: WorkspaceId): IsCurrentWorkspace {
const workspaceIdRef = useRef(workspaceId);
workspaceIdRef.current = workspaceId;
return useCallback((candidate) => workspaceIdRef.current === candidate, []);
}
function useConfirmSave({
workspaceId,
kind,
customQuery,
save,
markSearchInteracted,
setProgrammaticSelection,
setQueryImmediate,
setRepoFilter,
reportError,
isCurrentWorkspace,
}: {
workspaceId: WorkspaceId;
kind: SidebarSelection["kind"];
customQuery: string;
save: SavedPresetStore["save"];
markSearchInteracted: () => void;
setProgrammaticSelection: (selection: SidebarSelection) => void;
setQueryImmediate: (query: string) => void;
setRepoFilter: (repo: string) => void;
reportError: () => void;
isCurrentWorkspace: IsCurrentWorkspace;
}) {
const scope = useRef({ workspaceId });
if (scope.current.workspaceId !== workspaceId) scope.current = { workspaceId };
return useCallback(
async (label: string, defaultRepoFilter: string) => {
const startedIn = scope.current;
try {
const created = await save({ kind, label, customQuery, repoFilter: defaultRepoFilter });
// No persistence started when workspace presets are not available yet.
if (!created || scope.current !== startedIn || !isCurrentWorkspace(workspaceId))
return false;
markSearchInteracted();
setProgrammaticSelection({ kind, source: "saved", id: created.id });
setQueryImmediate(customQuery);
setRepoFilter(defaultRepoFilter);
return true;
} catch {
if (scope.current === startedIn && isCurrentWorkspace(workspaceId)) reportError();
return false;
}
},
[
customQuery,
isCurrentWorkspace,
kind,
markSearchInteracted,
reportError,
save,
setProgrammaticSelection,
setQueryImmediate,
setRepoFilter,
workspaceId,
],
);
}
function useDeleteSaved({
workspaceId,
selection,
prPresets,
issuePresets,
remove,
markSearchInteracted,
setProgrammaticSelection,
setQueryImmediate,
setRepoFilter,
reportError,
isCurrentWorkspace,
}: {
workspaceId: WorkspaceId;
selection: SidebarSelection;
prPresets: PresetOption[];
issuePresets: PresetOption[];
remove: SavedPresetStore["remove"];
markSearchInteracted: () => void;
setProgrammaticSelection: (selection: SidebarSelection) => void;
setQueryImmediate: (query: string) => void;
setRepoFilter: (repo: string) => void;
reportError: () => void;
isCurrentWorkspace: IsCurrentWorkspace;
}) {
const selectionRef = useRef(selection);
selectionRef.current = selection;
const presetsRef = useRef({ prPresets, issuePresets });
presetsRef.current = { prPresets, issuePresets };
return useCallback(
async (id: string) => {
try {
const removed = await remove(id);
// Loading or a stale target is a no-op, not a persistence failure.
if (!removed) return;
if (!isCurrentWorkspace(workspaceId)) return;
markSearchInteracted();
const currentSelection = selectionRef.current;
if (currentSelection.source === "saved" && currentSelection.id === id) {
const fallback = firstPresetSelection(
currentSelection.kind,
presetsRef.current.prPresets,
presetsRef.current.issuePresets,
);
setProgrammaticSelection(fallback.selection);
setQueryImmediate(fallback.filter);
setRepoFilter("");
}
} catch {
reportError();
}
},
[
isCurrentWorkspace,
markSearchInteracted,
remove,
reportError,
setProgrammaticSelection,
setQueryImmediate,
setRepoFilter,
workspaceId,
],
);
}
export function useSavedPresetActions({
workspaceId,
selection,
customQuery,
resolvedPrPresets: prPresets,
resolvedIssuePresets: issuePresets,
setProgrammaticSelection,
setQueryImmediate,
setRepoFilter,
savedPresetStore,
markSearchInteracted,
}: SavedPresetActionsOptions) {
const { t } = useTranslation();
const { toast } = useToast();
const [, setDefaultMutationVersion] = useState(0);
const pendingDefaultMutationsRef = useRef(new Map<WorkspaceId, PendingDefaultMutation>());
const isCurrentWorkspace = useIsCurrentWorkspace(workspaceId);
const pendingDefaultMutation = pendingDefaultMutationsRef.current.get(workspaceId);
const defaultMutationPendingId = pendingDefaultMutation?.presetId ?? null;
const {
presets: savedPresets,
save: saveSavedPreset,
remove: removeSavedPreset,
setDefault: setSavedPresetDefault,
} = savedPresetStore;
const reportSaveError = useCallback(
() => toast({ description: t("integrations:failedToSaveSavedQuery"), variant: "error" }),
[t, toast],
);
const reportDeleteError = useCallback(
() => toast({ description: t("integrations:failedToDeleteSavedQuery"), variant: "error" }),
[t, toast],
);
const onConfirmSave = useConfirmSave({
workspaceId,
kind: selection.kind,
customQuery,
save: saveSavedPreset,
markSearchInteracted,
setProgrammaticSelection,
setQueryImmediate,
setRepoFilter,
reportError: reportSaveError,
isCurrentWorkspace,
});
const onDeleteSaved = useDeleteSaved({
workspaceId,
selection,
prPresets,
issuePresets,
remove: removeSavedPreset,
markSearchInteracted,
setProgrammaticSelection,
setQueryImmediate,
setRepoFilter,
reportError: reportDeleteError,
isCurrentWorkspace,
});
const onToggleSavedDefault = useCallback(
async (preset: SavedPreset) => {
const pendingMutations = pendingDefaultMutationsRef.current;
if (pendingMutations.has(workspaceId)) return;
const pendingMutation: PendingDefaultMutation = { presetId: preset.id };
pendingMutations.set(workspaceId, pendingMutation);
setDefaultMutationVersion((version) => version + 1);
try {
const defaultId = preset.isDefault ? null : preset.id;
const persisted = await setSavedPresetDefault(preset.kind, defaultId);
// Loading or an already-matching default is a no-op, not a persistence failure.
if (!persisted) return;
if (!isCurrentWorkspace(workspaceId)) return;
// Mark interaction so the initial-selection effect does not switch the
// active view to the newly set default on the same render cycle.
markSearchInteracted();
} catch {
toast({
description: t("integrations:failedToUpdateDefaultView"),
variant: "error",
});
} finally {
if (pendingMutations.get(workspaceId) === pendingMutation) {
pendingMutations.delete(workspaceId);
if (isCurrentWorkspace(workspaceId)) {
setDefaultMutationVersion((version) => version + 1);
}
}
}
},
[isCurrentWorkspace, markSearchInteracted, setSavedPresetDefault, t, toast, workspaceId],
);
return {
savedPresets,
onConfirmSave,
onDeleteSaved,
onToggleSavedDefault,
defaultMutationPendingId,
};
}