1import { useScopedSettingsWriteAllowed } from "./useScopedSettings";
2import { AuthSettingsWriteScope } from "@t3tools/contracts";
3import { EnvironmentId, type T3ProjectFileScript } from "@t3tools/contracts";
4import {
5 isAtomCommandInterrupted,
6 squashAtomCommandFailure,
7} from "@t3tools/client-runtime/state/runtime";
8import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
9import { ChevronDownIcon, PlusIcon } from "lucide-react";
10import { useCallback, useMemo, useState } from "react";
11import { useT3ProjectFileState } from "../../hooks/useT3ProjectFileScripts";
12import { useEnvironments } from "../../state/environments";
13import {
14 EMPTY_PROJECT_SCRIPT_INPUT,
15 editorRequestForScript,
16 ProjectScriptEditorDialog,
17 ScriptIcon,
18 type NewProjectScriptInput,
19 type ProjectScriptEditorRequest,
20} from "../projectScriptEditor";
21import { Button } from "../ui/button";
22import {
23 Menu,
24 MenuGroup,
25 MenuGroupLabel,
26 MenuItem,
27 MenuPopup,
28 MenuSeparator,
29 MenuTrigger,
30} from "../ui/menu";
31import { ProjectActionsList } from "./ProjectActionsList";
32import { useProjectScriptSettings } from "./useProjectScriptSettings";
33import { SettingsRow, SettingsSection } from "./settingsLayout";
34import { useSettingsScope } from "./SettingsScopeContext";
35
36/**
37 * A project's actions on each selected environment. Actions belong to a
38 * project, so this only renders at a project scope; the environment's
39 * `defaultProjectScripts` is the layer a project without its own list
40 * inherits. Shortcuts are environment-wide, so the same action id shares its
41 * binding on an environment.
42 */
43export function ProjectActionsSettings() {
44 const { scope, targets, target } = useSettingsScope();
45 const { environments } = useEnvironments();
46 const isProjectScope = scope.kind === "project" || scope.kind === "checkout";
47 const representativeConfig = target
48 ? environments.find((environment) => environment.environmentId === target.environmentId)
49 ?.serverConfig
50 : undefined;
51 const scripts = target?.settings.defaultProjectScripts ?? [];
52 const keybindings = representativeConfig?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS;
53 const mixed = targets.some(
54 (candidate) =>
55 JSON.stringify(candidate.settings.defaultProjectScripts) !== JSON.stringify(scripts),
56 );
57 const canWriteSettings = useScopedSettingsWriteAllowed();
58 const [request, setRequest] = useState<ProjectScriptEditorRequest | null>(null);
59 const memberById = new Map(
60 isProjectScope ? scope.members.map((member) => [member.id, member]) : [],
61 );
62 const { saving, persist, submit } = useProjectScriptSettings(
63 targets.flatMap((candidate) => {
64 const environment = environments.find(
65 (entry) => entry.environmentId === candidate.environmentId,
66 );
67 if (!environment?.serverConfig) return [];
68 const member = candidate.projectId ? memberById.get(candidate.projectId) : undefined;
69 // An older server ignores the override record, so a project edit there
70 // would report success and vanish; such environments are left out and
71 // the legacy per-project map keeps serving them.
72 if (
73 member &&
74 environment.serverConfig.environment?.capabilities.projectSettingsOverrides !== true
75 ) {
76 return [];
77 }
78 return [
79 {
80 environmentId: candidate.environmentId,
81 // Writes read the raw environment settings so an override entry is
82 // extended, not derived from already-resolved values.
83 settings: environment.serverConfig.settings,
84 keybindings: environment.serverConfig.keybindings,
85 ...(member ? { project: member } : {}),
86 },
87 ];
88 }),
89 );
90
91 // A project's t3.json can declare actions to import. Read it from the
92 // representative checkout; the imported action still fans out.
93 const representativeMember = target?.projectId ? memberById.get(target.projectId) : undefined;
94 const t3File = useT3ProjectFileState(
95 representativeMember?.environmentId ?? EnvironmentId.make("none"),
96 representativeMember?.workspaceRoot ?? null,
97 );
98 const importableScripts = useMemo(
99 () =>
100 t3File.scripts.filter(
101 (fileScript) =>
102 !scripts.some(
103 (script) =>
104 script.command === fileScript.command ||
105 script.name.toLowerCase() === fileScript.name.toLowerCase(),
106 ),
107 ),
108 [scripts, t3File.scripts],
109 );
110 const importFileScript = useCallback(
111 async (fileScript: T3ProjectFileScript) => {
112 const payload: NewProjectScriptInput = {
113 name: fileScript.name,
114 command: fileScript.command,
115 icon: fileScript.icon ?? "play",
116 runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false,
117 waitForSetup: fileScript.runOnWorktreeCreate === true && fileScript.async === false,
118 runOnSettle: fileScript.runOnSettle ?? false,
119 keybinding: null,
120 previewUrl: fileScript.previewUrl ?? null,
121 autoOpenPreview: fileScript.previewUrl ? (fileScript.autoOpenPreview ?? false) : false,
122 };
123 const result = await submit(null, payload);
124 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
125 const error = squashAtomCommandFailure(result);
126 setRequest({
127 scriptId: null,
128 initial: payload,
129 error: error instanceof Error ? error.message : "Failed to import action.",
130 });
131 }
132 },
133 [submit],
134 );
135
136 return (
137 <SettingsSection id="project-actions" title="Actions">
138 <SettingsRow
139 serverScoped
140 settingKeys={["defaultProjectScripts"]}
141 mixed={mixed}
142 title="Actions"
143 description="Commands that run in this project's checkout or its worktree, with optional shortcuts."
144 onResetOverride={() => void persist(() => null)}
145 control={
146 <div className="flex flex-wrap items-center gap-1.5">
147 {importableScripts.length > 0 ? (
148 <Menu>
149 <MenuTrigger
150 render={
151 <Button
152 id="import-scripts"
153 size="xs"
154 variant="ghost"
155 disabled={saving || !canWriteSettings}
156 type="button"
157 />
158 }
159 >
160 Import scripts
161 <ChevronDownIcon className="size-3.5" />
162 </MenuTrigger>
163 <MenuPopup align="end">
164 <MenuGroup>
165 <MenuGroupLabel>Import from t3.json</MenuGroupLabel>
166 <p className="px-2 pb-2 text-pretty text-sm text-muted-foreground">
167 Add actions declared by this checkout without editing them first.
168 </p>
169 </MenuGroup>
170 <MenuSeparator />
171 {importableScripts.map((fileScript) => (
172 <MenuItem
173 key={`${fileScript.name} ${fileScript.command}`}
174 onClick={() => void importFileScript(fileScript)}
175 >
176 <ScriptIcon icon={fileScript.icon ?? "play"} className="size-4 shrink-0" />
177 <div className="min-w-0 flex-1">
178 <div className="truncate font-medium">{fileScript.name}</div>
179 <div className="truncate font-mono text-muted-foreground">
180 {fileScript.command}
181 </div>
182 </div>
183 </MenuItem>
184 ))}
185 </MenuPopup>
186 </Menu>
187 ) : null}
188 <Button
189 size="xs"
190 variant="outline"
191 disabled={saving || !canWriteSettings || targets.length === 0}
192 onClick={() => setRequest({ scriptId: null, initial: EMPTY_PROJECT_SCRIPT_INPUT })}
193 >
194 <PlusIcon className="size-3.5" />
195 Add action
196 </Button>
197 </div>
198 }
199 />
200 {mixed ? (
201 <SettingsRow
202 title="Different actions across environments"
203 description="Choose one environment to edit its list. Adding an action here adds it on every selected environment."
204 />
205 ) : (
206 <ProjectActionsList
207 scripts={scripts}
208 keybindings={keybindings}
209 disabled={saving || !canWriteSettings}
210 onEdit={(script) => setRequest(editorRequestForScript(script, keybindings))}
211 />
212 )}
213 {t3File.status === "invalid" ? (
214 <SettingsRow
215 title="t3.json is invalid"
216 description="A t3.json exists in this checkout but fails to parse, so every action and icon it declares is ignored. Check the JSON syntax and icon values."
217 className="text-warning"
218 />
219 ) : null}
220 {target && (
221 <ProjectScriptEditorDialog
222 environmentId={target.environmentId}
223 editScope={AuthSettingsWriteScope}
224 request={request}
225 scripts={scripts}
226 onSubmit={submit}
227 onDelete={(id) =>
228 void persist((current) => current.filter((script) => script.id !== id), id, null)
229 }
230 onClose={() => setRequest(null)}
231 />
232 )}
233 </SettingsSection>
234 );
235}