1import { AuthSettingsWriteScope, EnvironmentAuthorizationError } from "@t3tools/contracts";
2import { readEnvironmentScope } from "../../state/session";
3import {
4 isAtomCommandInterrupted,
5 mapAtomCommandResult,
6 squashAtomCommandFailure,
7 type AtomCommandResult,
8} from "@t3tools/client-runtime/state/runtime";
9import {
10 type EnvironmentId,
11 type ProjectId,
12 type ProjectScript,
13 type ResolvedKeybindingsConfig,
14 type ServerSettings,
15} from "@t3tools/contracts";
16import { resolveProjectScripts } from "@t3tools/shared/projectScripts";
17import { clearProjectSettingsOverrides } from "@t3tools/shared/projectSettings";
18import * as Cause from "effect/Cause";
19import { AsyncResult } from "effect/reactivity";
20import { useRef, useState } from "react";
21
22import {
23 decodeProjectScriptKeybindingRule,
24 keybindingValueForCommand,
25} from "../../lib/projectScriptKeybindings";
26import {
27 buildProjectScript,
28 commandForProjectScript,
29 nextProjectScriptId,
30 releaseClaimedRoles,
31} from "../../projectScripts";
32import { useProjects } from "../../state/entities";
33import { serverEnvironment } from "../../state/server";
34import { useAtomCommand } from "../../state/use-atom-command";
35import type { NewProjectScriptInput } from "../projectScriptEditor";
36import { toastManager } from "../ui/toast";
37
38function reportScriptFailure(result: AtomCommandResult<unknown, unknown>) {
39 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
40 const error = squashAtomCommandFailure(result);
41 toastManager.add({
42 type: "error",
43 title: "Failed to save project actions",
44 description: error instanceof Error ? error.message : "An error occurred.",
45 });
46 }
47 return mapAtomCommandResult(result, () => undefined);
48}
49
50/**
51 * Edits the action list on every target: the environment default when there is
52 * no project, else that project's override entry, then saves changed shortcuts.
53 */
54export function useProjectScriptSettings(
55 targets: readonly {
56 environmentId: EnvironmentId;
57 settings: ServerSettings;
58 keybindings: ResolvedKeybindingsConfig;
59 project?: { id: ProjectId; scripts: readonly ProjectScript[] };
60 }[],
61) {
62 const projects = useProjects();
63 const [saving, setSaving] = useState(false);
64 const savingRef = useRef(false);
65 const updateSettings = useAtomCommand(serverEnvironment.updateSettings, "project actions update");
66 const upsertKeybinding = useAtomCommand(
67 serverEnvironment.upsertKeybinding,
68 "action shortcut update",
69 );
70 const removeKeybinding = useAtomCommand(
71 serverEnvironment.removeKeybinding,
72 "action shortcut removal",
73 );
74
75 async function persist(
76 transform: (current: readonly ProjectScript[]) => readonly ProjectScript[] | null,
77 scriptId?: string,
78 keybinding?: string | null,
79 ): Promise<AtomCommandResult<void, unknown>> {
80 if (savingRef.current || targets.length === 0) {
81 const message = "No available machine, or another action change is saving.";
82 toastManager.add({ type: "error", title: "Actions not saved", description: message });
83 return AsyncResult.failure(Cause.fail(new Error(message)));
84 }
85 if (
86 targets.some(
87 ({ environmentId }) => !readEnvironmentScope(environmentId, AuthSettingsWriteScope),
88 )
89 ) {
90 return reportScriptFailure(
91 AsyncResult.failure(
92 Cause.fail(
93 new EnvironmentAuthorizationError({
94 requiredScope: AuthSettingsWriteScope,
95 message: "This connection cannot change environment settings.",
96 }),
97 ),
98 ),
99 );
100 }
101 savingRef.current = true;
102 setSaving(true);
103 try {
104 for (const { environmentId, settings, keybindings, project } of targets) {
105 const current = project
106 ? resolveProjectScripts(settings, project)
107 : settings.defaultProjectScripts;
108 const nextScripts = transform(current);
109 const effectiveScripts = nextScripts ?? settings.defaultProjectScripts;
110 const result = await updateSettings({
111 environmentId,
112 input: {
113 patch: project
114 ? {
115 projectSettingsOverrides: {
116 [project.id]:
117 nextScripts === null
118 ? clearProjectSettingsOverrides(settings, project.id, [
119 "defaultProjectScripts",
120 ])
121 : {
122 ...settings.projectSettingsOverrides[project.id],
123 defaultProjectScripts: nextScripts,
124 },
125 },
126 }
127 : { defaultProjectScripts: nextScripts ?? [] },
128 },
129 });
130 if (result._tag === "Failure") return reportScriptFailure(result);
131 const changedIds = scriptId
132 ? [scriptId]
133 : current
134 .filter((script) => !effectiveScripts.some((next) => next.id === script.id))
135 .map((script) => script.id);
136 for (const id of changedIds) {
137 const command = commandForProjectScript(id);
138 const previousValue = keybindingValueForCommand(keybindings, command);
139 const previous = previousValue
140 ? decodeProjectScriptKeybindingRule({ keybinding: previousValue, command })
141 : null;
142 const next = decodeProjectScriptKeybindingRule({ keybinding, command });
143 const retainedElsewhere =
144 !nextScripts?.some((script) => script.id === id) &&
145 ((project && settings.defaultProjectScripts.some((script) => script.id === id)) ||
146 Object.entries(settings.projectSettingsOverrides).some(
147 ([projectId, entry]) =>
148 projectId !== project?.id &&
149 entry.defaultProjectScripts?.some((script) => script.id === id),
150 ) ||
151 projects.some(
152 (other) =>
153 other.environmentId === environmentId &&
154 other.id !== project?.id &&
155 (project ? resolveProjectScripts(settings, other) : other.scripts).some(
156 (script) => script.id === id,
157 ),
158 ));
159 const bindingResult = next
160 ? await upsertKeybinding({
161 environmentId,
162 input:
163 previous && previous.key !== next.key ? { ...next, replace: previous } : next,
164 })
165 : previous && !retainedElsewhere
166 ? await removeKeybinding({ environmentId, input: previous })
167 : null;
168 if (bindingResult?._tag === "Failure") return reportScriptFailure(bindingResult);
169 }
170 }
171 return AsyncResult.success(undefined);
172 } finally {
173 savingRef.current = false;
174 setSaving(false);
175 }
176 }
177
178 function submit(scriptId: string | null, input: NewProjectScriptInput) {
179 const existingIds = [
180 ...projects.flatMap((project) => project.scripts.map((script) => script.id)),
181 ...targets.flatMap(({ settings, project }) =>
182 [
183 ...settings.defaultProjectScripts,
184 ...Object.values(settings.projectSettingsOverrides).flatMap(
185 (entry) => entry.defaultProjectScripts ?? [],
186 ),
187 ...(project?.scripts ?? []),
188 ].map((script) => script.id),
189 ),
190 ];
191 const id = scriptId ?? nextProjectScriptId(input.name, existingIds);
192 const next = buildProjectScript(id, input);
193 return persist(
194 (current) => {
195 const updated = current.map((script) =>
196 script.id === id ? next : releaseClaimedRoles(script, input),
197 );
198 return scriptId === null ? [...updated, next] : updated;
199 },
200 id,
201 input.keybinding,
202 );
203 }
204
205 return { saving, persist, submit };
206}