Open History to see the last commit
1import type {
2 KeybindingCommand,
3 ProjectId,
4 ProjectScript,
5 EnvironmentId,
6} from "@t3tools/contracts";
7import { AuthSettingsWriteScope, EnvironmentAuthorizationError } from "@t3tools/contracts";
8import type { UnifiedSettings } from "@t3tools/contracts/settings";
9import {
10 isAtomCommandInterrupted,
11 mapAtomCommandResult,
12 squashAtomCommandFailure,
13 type AtomCommandResult,
14} from "@t3tools/client-runtime/state/runtime";
15import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
16import { resolveProjectScripts } from "@t3tools/shared/projectScripts";
17import * as Cause from "effect/Cause";
18import { AsyncResult } from "effect/reactivity";
19import { useStableCallback } from "../../hooks/useStableCallback";
20import {
21 decodeProjectScriptKeybindingRule,
22 keybindingValueForCommand,
23} from "../../lib/projectScriptKeybindings";
24import {
25 buildProjectScript,
26 commandForProjectScript,
27 nextProjectScriptId,
28 projectScriptIdFromCommand,
29 releaseClaimedRoles,
30} from "../../projectScripts";
31import { appAtomRegistry } from "../../rpc/atomRegistry";
32import { serverEnvironment } from "../../state/server";
33import { readEnvironmentScope } from "../../state/session";
34import { useAtomCommand } from "../../state/use-atom-command";
35import { useProjects } from "../../state/entities";
36import { type NewProjectScriptInput } from "../ProjectScriptsControl";
37import { stackedThreadToast, toastManager } from "../ui/toast";
38
39/** Write script settings before reconciling shortcuts in environment keybindings. */
40export function useProjectScriptEditing(input: {
41 readonly environmentId: EnvironmentId;
42 readonly project: EnvironmentProject | null;
43 readonly scripts: ReadonlyArray<ProjectScript>;
44 readonly settings: UnifiedSettings;
45}) {
46 const { environmentId, project, scripts, settings } = input;
47 const allProjects = useProjects();
48 const updateProjectScriptSettings = useAtomCommand(serverEnvironment.updateSettings, {
49 reportFailure: false,
50 });
51 const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, {
52 reportFailure: false,
53 });
54 const removeKeybinding = useAtomCommand(serverEnvironment.removeKeybinding, {
55 reportFailure: false,
56 });
57 const persistProjectScripts = async (input: {
58 projectId: ProjectId;
59 projectCwd: string;
60 previousScripts: ReadonlyArray<ProjectScript>;
61 nextScripts: ReadonlyArray<ProjectScript>;
62 keybinding: NewProjectScriptInput["keybinding"];
63 keybindingCommand: KeybindingCommand | null;
64 }): Promise<AtomCommandResult<void, unknown>> => {
65 const serverConfig = appAtomRegistry.get(serverEnvironment.configValueAtom(environmentId));
66 const previousKeybinding = keybindingValueForCommand(
67 serverConfig?.keybindings ?? [],
68 input.keybindingCommand,
69 );
70 const isDeletingScript = !input.nextScripts.some(
71 (script) => commandForProjectScript(script.id) === input.keybindingCommand,
72 );
73 const changesKeybinding =
74 input.keybinding !== undefined &&
75 (input.keybinding?.trim() || null) !== previousKeybinding &&
76 (!isDeletingScript || readEnvironmentScope(environmentId, AuthSettingsWriteScope));
77 if (changesKeybinding && !readEnvironmentScope(environmentId, AuthSettingsWriteScope)) {
78 return AsyncResult.failure(
79 Cause.fail(
80 new EnvironmentAuthorizationError({
81 requiredScope: AuthSettingsWriteScope,
82 message: "This connection cannot change keyboard shortcuts.",
83 }),
84 ),
85 );
86 }
87 const keybindingRule = changesKeybinding
88 ? decodeProjectScriptKeybindingRule({
89 keybinding: input.keybinding,
90 command: input.keybindingCommand,
91 })
92 : null;
93 const updateResult = mapAtomCommandResult(
94 await updateProjectScriptSettings({
95 environmentId,
96 input: {
97 patch:
98 serverConfig?.environment.capabilities.projectSettingsOverrides === true
99 ? {
100 projectSettingsOverrides: {
101 [input.projectId]: {
102 ...settings.projectSettingsOverrides[input.projectId],
103 defaultProjectScripts: input.nextScripts,
104 },
105 },
106 }
107 : {
108 projectScriptOverrides: {
109 [input.projectId]: input.nextScripts,
110 },
111 },
112 },
113 }),
114 () => undefined,
115 );
116 if (updateResult._tag === "Failure") {
117 return updateResult;
118 }
119
120 if (!changesKeybinding) return updateResult;
121 if (!readEnvironmentScope(environmentId, AuthSettingsWriteScope)) {
122 return AsyncResult.failure(
123 Cause.fail(
124 new EnvironmentAuthorizationError({
125 requiredScope: AuthSettingsWriteScope,
126 message: isDeletingScript
127 ? "The script was deleted, but its keyboard shortcut could not be removed because permission changed."
128 : "The script was saved, but this connection can no longer change keyboard shortcuts.",
129 }),
130 ),
131 );
132 }
133
134 const scriptId = input.keybindingCommand
135 ? projectScriptIdFromCommand(input.keybindingCommand)
136 : null;
137 if (!keybindingRule && !input.previousScripts.some((script) => script.id === scriptId)) {
138 return updateResult;
139 }
140 const retainedElsewhere =
141 !input.nextScripts.some((script) => script.id === scriptId) &&
142 (settings.defaultProjectScripts.some((script) => script.id === scriptId) ||
143 Object.entries(settings.projectSettingsOverrides).some(
144 ([projectId, entry]) =>
145 projectId !== input.projectId &&
146 entry.defaultProjectScripts?.some((script) => script.id === scriptId),
147 ) ||
148 allProjects.some(
149 (other) =>
150 other.environmentId === environmentId &&
151 other.id !== input.projectId &&
152 resolveProjectScripts(settings, other).some((script) => script.id === scriptId),
153 ));
154 if (!keybindingRule && retainedElsewhere) return updateResult;
155
156 const previousRules = (serverConfig?.keybindings ?? []).flatMap((binding) => {
157 if (binding.command !== input.keybindingCommand || binding.whenAst) return [];
158 const previous = decodeProjectScriptKeybindingRule({
159 keybinding: keybindingValueForCommand([binding], input.keybindingCommand),
160 command: input.keybindingCommand,
161 });
162 return previous ? [previous] : [];
163 });
164 const previous = previousRules.at(-1);
165 for (const rule of keybindingRule ? previousRules.slice(0, -1) : previousRules) {
166 const result = await removeKeybinding({ environmentId, input: rule });
167 if (result._tag === "Failure") return mapAtomCommandResult(result, () => undefined);
168 }
169 return keybindingRule
170 ? mapAtomCommandResult(
171 await upsertKeybinding({
172 environmentId,
173 input:
174 previous && previous.key !== keybindingRule.key
175 ? { ...keybindingRule, replace: previous }
176 : keybindingRule,
177 }),
178 () => undefined,
179 )
180 : updateResult;
181 };
182 const saveProjectScript = async (
183 input: NewProjectScriptInput,
184 ): Promise<AtomCommandResult<void, unknown>> => {
185 if (!project) {
186 return AsyncResult.success(undefined);
187 }
188 const nextId = nextProjectScriptId(
189 input.name,
190 scripts.map((script) => script.id),
191 );
192 const nextScript = buildProjectScript(nextId, input);
193 const nextScripts = [
194 ...scripts.map((script) => releaseClaimedRoles(script, input)),
195 nextScript,
196 ];
197
198 return persistProjectScripts({
199 projectId: project.id,
200 projectCwd: project.workspaceRoot,
201 previousScripts: scripts,
202 nextScripts,
203 keybinding: input.keybinding,
204 keybindingCommand: commandForProjectScript(nextId),
205 });
206 };
207 const updateProjectScript = async (
208 scriptId: string,
209 input: NewProjectScriptInput,
210 ): Promise<AtomCommandResult<void, unknown>> => {
211 if (!project) {
212 return AsyncResult.success(undefined);
213 }
214 const existingScript = scripts.find((script) => script.id === scriptId);
215 if (!existingScript) {
216 return AsyncResult.failure(Cause.fail(new Error("Script not found.")));
217 }
218
219 const updatedScript = buildProjectScript(existingScript.id, input);
220 const nextScripts = scripts.map((script) =>
221 script.id === scriptId ? updatedScript : releaseClaimedRoles(script, input),
222 );
223
224 return persistProjectScripts({
225 projectId: project.id,
226 projectCwd: project.workspaceRoot,
227 previousScripts: scripts,
228 nextScripts,
229 keybinding: input.keybinding,
230 keybindingCommand: commandForProjectScript(scriptId),
231 });
232 };
233 const deleteProjectScript = async (
234 scriptId: string,
235 ): Promise<AtomCommandResult<void, unknown>> => {
236 if (!project) {
237 return AsyncResult.success(undefined);
238 }
239 const nextScripts = scripts.filter((script) => script.id !== scriptId);
240
241 const deletedName = scripts.find((s) => s.id === scriptId)?.name;
242
243 const result = await persistProjectScripts({
244 projectId: project.id,
245 projectCwd: project.workspaceRoot,
246 previousScripts: scripts,
247 nextScripts,
248 keybinding: null,
249 keybindingCommand: commandForProjectScript(scriptId),
250 });
251 if (result._tag === "Success") {
252 toastManager.add({
253 type: "success",
254 title: `Deleted action "${deletedName ?? "Unknown"}"`,
255 });
256 } else if (!isAtomCommandInterrupted(result)) {
257 const error = squashAtomCommandFailure(result);
258 toastManager.add(
259 stackedThreadToast({
260 type: "error",
261 title: "Could not delete action",
262 description: error instanceof Error ? error.message : "An unexpected error occurred.",
263 }),
264 );
265 }
266 return result;
267 };
268 return {
269 saveProjectScript: useStableCallback(saveProjectScript),
270 updateProjectScript: useStableCallback(updateProjectScript),
271 deleteProjectScript: useStableCallback(deleteProjectScript),
272 };
273}