Skip to content
Source
Sign inSign up
Open History to see the last commit

ProjectActionsSettings.tsx

209 lines8.1 KB

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 { 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​
36export function ProjectActionsSettings() {
37 const { scope, targets, target } = useSettingsScope();
38 const { environments } = useEnvironments();
39 const isProjectScope = scope.kind === "project" || scope.kind === "checkout";
40 const representativeConfig = target
41 ? environments.find((environment) => environment.environmentId === target.environmentId)
42 ?.serverConfig
43 : undefined;
44 const scripts = target?.settings.defaultProjectScripts ?? [];
45 const keybindings = representativeConfig?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS;
46 const mixed = targets.some(
47 (candidate) =>
48 JSON.stringify(candidate.settings.defaultProjectScripts) !== JSON.stringify(scripts),
49 );
50 const canWriteSettings = useScopedSettingsWriteAllowed();
51 const [request, setRequest] = useState<ProjectScriptEditorRequest | null>(null);
52 const memberById = new Map(
53 isProjectScope ? scope.members.map((member) => [member.id, member]) : [],
54 );
55 const { saving, persist, submit } = useProjectScriptSettings(
56 targets.flatMap((candidate) => {
57 const environment = environments.find(
58 (entry) => entry.environmentId === candidate.environmentId,
59 );
60 if (!environment?.serverConfig) return [];
61 const member = candidate.projectId ? memberById.get(candidate.projectId) : undefined;
62 return [
63 {
64 environmentId: candidate.environmentId,
65 settings: environment.serverConfig.settings,
66 keybindings: environment.serverConfig.keybindings,
67 ...(member ? { project: member } : {}),
68 },
69 ];
70 }),
71 );
72​
73 const representativeMember = target?.projectId ? memberById.get(target.projectId) : undefined;
74 const t3File = useT3ProjectFileState(
75 representativeMember?.environmentId ?? EnvironmentId.make("none"),
76 representativeMember?.workspaceRoot ?? null,
77 );
78 const importableScripts = t3File.scripts.filter(
79 (fileScript) =>
80 !scripts.some(
81 (script) =>
82 script.command === fileScript.command ||
83 script.name.toLowerCase() === fileScript.name.toLowerCase(),
84 ),
85 );
86 const importFileScript = async (fileScript: T3ProjectFileScript) => {
87 const payload: NewProjectScriptInput = {
88 name: fileScript.name,
89 command: fileScript.command,
90 icon: fileScript.icon ?? "play",
91 runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false,
92 waitForSetup: fileScript.runOnWorktreeCreate === true && fileScript.async === false,
93 runOnSettle: fileScript.runOnSettle ?? false,
94 stopOnSettle: fileScript.stopOnSettle ?? false,
95 keybinding: null,
96 previewUrl: fileScript.previewUrl ?? null,
97 autoOpenPreview: fileScript.previewUrl ? (fileScript.autoOpenPreview ?? false) : false,
98 };
99 const result = await submit(null, payload);
100 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
101 const error = squashAtomCommandFailure(result);
102 setRequest({
103 scriptId: null,
104 initial: payload,
105 error: error instanceof Error ? error.message : "Failed to import action.",
106 });
107 }
108 };
109​
110 return (
111 <SettingsSection id="project-actions" title="Actions">
112 <SettingsRow
113 serverScoped
114 settingKeys={["defaultProjectScripts"]}
115 mixed={mixed}
116 title="Actions"
117 description="Commands that run in this project's checkout or its worktree, with optional shortcuts."
118 onResetOverride={() => void persist(() => null)}
119 control={
120 <div className="flex flex-wrap items-center gap-1.5">
121 {importableScripts.length > 0 ? (
122 <Menu>
123 <MenuTrigger
124 render={
125 <Button
126 id="import-scripts"
127 size="xs"
128 variant="ghost"
129 disabled={saving || !canWriteSettings}
130 type="button"
131 />
132 }
133 >
134 Import scripts
135 <ChevronDownIcon className="size-3.5" />
136 </MenuTrigger>
137 <MenuPopup align="end">
138 <MenuGroup>
139 <MenuGroupLabel>Import from t3.json</MenuGroupLabel>
140 <p className="px-2 pb-2 text-pretty text-sm text-muted-foreground">
141 Add actions declared by this checkout without editing them first.
142 </p>
143 </MenuGroup>
144 <MenuSeparator />
145 {importableScripts.map((fileScript) => (
146 <MenuItem
147 key={`${fileScript.name} ${fileScript.command}`}
148 onClick={() => void importFileScript(fileScript)}
149 >
150 <ScriptIcon icon={fileScript.icon ?? "play"} className="size-4 shrink-0" />
151 <div className="min-w-0 flex-1">
152 <div className="truncate font-medium">{fileScript.name}</div>
153 <div className="truncate font-mono text-muted-foreground">
154 {fileScript.command}
155 </div>
156 </div>
157 </MenuItem>
158 ))}
159 </MenuPopup>
160 </Menu>
161 ) : null}
162 <Button
163 size="xs"
164 variant="outline"
165 disabled={saving || !canWriteSettings || targets.length === 0}
166 onClick={() => setRequest({ scriptId: null, initial: EMPTY_PROJECT_SCRIPT_INPUT })}
167 >
168 <PlusIcon className="size-3.5" />
169 Add action
170 </Button>
171 </div>
172 }
173 />
174 {mixed ? (
175 <SettingsRow
176 title="Different actions across checkouts"
177 description="Narrow the selection above to one checkout to edit its list. Adding an action here adds it to every selected checkout."
178 />
179 ) : (
180 <ProjectActionsList
181 scripts={scripts}
182 keybindings={keybindings}
183 disabled={saving || !canWriteSettings}
184 onEdit={(script) => setRequest(editorRequestForScript(script, keybindings))}
185 />
186 )}
187 {t3File.status === "invalid" ? (
188 <SettingsRow
189 title="t3.json is invalid"
190 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."
191 className="text-warning"
192 />
193 ) : null}
194 {target && (
195 <ProjectScriptEditorDialog
196 environmentId={target.environmentId}
197 editScope={AuthSettingsWriteScope}
198 request={request}
199 scripts={scripts}
200 onSubmit={submit}
201 onDelete={(id) =>
202 void persist((current) => current.filter((script) => script.id !== id), id, null)
203 }
204 onClose={() => setRequest(null)}
205 />
206 )}
207 </SettingsSection>
208 );