| 1 | import { AuthSettingsWriteScope, EnvironmentAuthorizationError } from "@t3tools/contracts"; |
| 2 | import { readEnvironmentScope } from "../../state/session"; |
| 3 | import { |
| 4 | isAtomCommandInterrupted, |
| 5 | mapAtomCommandResult, |
| 6 | squashAtomCommandFailure, |
| 7 | type AtomCommandResult, |
| 8 | } from "@t3tools/client-runtime/state/runtime"; |
| 9 | import { |
| 10 | type EnvironmentId, |
| 11 | type ProjectId, |
| 12 | type ProjectScript, |
| 13 | type ResolvedKeybindingsConfig, |
| 14 | type ServerSettings, |
| 15 | } from "@t3tools/contracts"; |
| 16 | import { resolveProjectScripts } from "@t3tools/shared/projectScripts"; |
| 17 | import { clearProjectSettingsOverrides } from "@t3tools/shared/projectSettings"; |
| 18 | import * as Cause from "effect/Cause"; |
| 19 | import { AsyncResult } from "effect/reactivity"; |
| 20 | import { useRef, useState } from "react"; |
| 21 | |
| 22 | import { |
| 23 | decodeProjectScriptKeybindingRule, |
| 24 | keybindingValueForCommand, |
| 25 | } from "../../lib/projectScriptKeybindings"; |
| 26 | import { |
| 27 | buildProjectScript, |
| 28 | commandForProjectScript, |
| 29 | nextProjectScriptId, |
| 30 | releaseClaimedRoles, |
| 31 | } from "../../projectScripts"; |
| 32 | import { useProjects } from "../../state/entities"; |
| 33 | import { serverEnvironment } from "../../state/server"; |
| 34 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 35 | import type { NewProjectScriptInput } from "../projectScriptEditor"; |
| 36 | import { toastManager } from "../ui/toast"; |
| 37 | |
| 38 | function 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 | */ |
| 54 | export 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 | } |