Skip to content
Source
Sign inSign up

ProjectScriptsControl.tsx

470 lines17.1 KB

1import { ThreadDetailsControl } from "./chat/ThreadDetailsControl";
2import {
3 AuthSettingsWriteScope,
4 type EnvironmentId,
5 type ProjectScript,
6 type T3ProjectFileScript,
7} from "@t3tools/contracts";
8import { useAtomValue } from "@effect/atom-react";
9import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
10import {
11 isAtomCommandInterrupted,
12 squashAtomCommandFailure,
13} from "@t3tools/client-runtime/state/runtime";
14import { projectScriptMenuLabel } from "@t3tools/shared/projectScripts";
15import { ChevronDownIcon, DownloadIcon, PlusIcon, SettingsIcon, WrenchIcon } from "lucide-react";
16import React, { useState } from "react";
17​
18import { commandForProjectScript, primaryProjectScript } from "~/projectScripts";
19import { shortcutLabelForCommand } from "~/keybindings";
20import { serverEnvironment } from "~/state/server";
21import { readEnvironmentScope } from "~/state/session";
22import {
23 EMPTY_PROJECT_SCRIPT_INPUT,
24 editorRequestForScript,
25 ProjectScriptEditorDialog,
26 ScriptIcon,
27 type NewProjectScriptInput,
28 type ProjectScriptActionResult,
29 type ProjectScriptEditorRequest,
30} from "./projectScriptEditor";
31import { Button } from "./ui/button";
32import { Group, GroupSeparator } from "./ui/group";
33import {
34 Menu,
35 MenuGroup,
36 MenuGroupLabel,
37 MenuItem,
38 MenuItemLabel,
39 MenuPopup,
40 MenuSeparator,
41 MenuShortcut,
42 MenuTrigger,
43 MenuSub,
44 MenuSubTrigger,
45 MenuSubPopup,
46} from "./ui/menu";
47import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
48import { cn } from "~/lib/utils";
49import {
50 THREAD_DETAILS_PANEL_CHEVRON_CLASS,
51 THREAD_DETAILS_PANEL_ICON_CLASS,
52 THREAD_DETAILS_PANEL_LABEL_CLASS,
53 THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS,
54 THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS,
55} from "./chat/threadDetailsPanelStyles";
56​
57export type { NewProjectScriptInput, ProjectScriptActionResult };
58​
59const NO_FILE_SCRIPTS: ReadonlyArray<T3ProjectFileScript> = [];
60​
61interface ProjectScriptsControlProps {
62 displayMode?: "toolbar" | "panel";
63 presentation?: "toolbar" | "menu";
64 onRequestMenuClose?: () => void;
65 environmentId: EnvironmentId;
66 scripts: ReadonlyArray<ProjectScript>;
67 fileScripts?: ReadonlyArray<T3ProjectFileScript>;
68 preferredScriptId?: string | null;
69 onRunScript?: ((script: ProjectScript) => void) | undefined;
70 onAddScript: (input: NewProjectScriptInput) => Promise<ProjectScriptActionResult>;
71 onUpdateScript: (
72 scriptId: string,
73 input: NewProjectScriptInput,
74 ) => Promise<ProjectScriptActionResult>;
75 onDeleteScript: (scriptId: string) => Promise<ProjectScriptActionResult>;
77​
78export default function ProjectScriptsControl({
79 displayMode = "toolbar",
80 presentation = "toolbar",
81 onRequestMenuClose,
82 environmentId,
83 scripts,
84 fileScripts = NO_FILE_SCRIPTS,
85 preferredScriptId = null,
86 onRunScript,
87 onAddScript,
88 onUpdateScript,
89 onDeleteScript,
90}: ProjectScriptsControlProps) {
91 const isPanel = displayMode === "panel";
92 const ActionGroup = isPanel ? "div" : Group;
93 const panelAnchorRef = React.useRef<HTMLDivElement | null>(null);
94 const keybindings =
95 useAtomValue(serverEnvironment.configValueAtom(environmentId))?.keybindings ??
96 DEFAULT_RESOLVED_KEYBINDINGS;
97 const [actionsMenuOpen, setActionsMenuOpen] = useState({
98 presentation,
99 scripts: false,
100 imports: false,
101 });
102 if (actionsMenuOpen.presentation !== presentation) {
103 setActionsMenuOpen({ presentation, scripts: false, imports: false });
104 }
105 const [editorRequest, setEditorRequest] = useState<ProjectScriptEditorRequest | null>(null);
106​
107 const primaryScript =
108 (preferredScriptId ? scripts.find((script) => script.id === preferredScriptId) : undefined) ??
109 primaryProjectScript(scripts);
110 const importableScripts = fileScripts.filter(
111 (fileScript) =>
112 !scripts.some(
113 (script) =>
114 script.command === fileScript.command ||
115 script.name.toLowerCase() === fileScript.name.toLowerCase(),
116 ),
117 );
118​
119 const openAddDialog = () => {
120 setEditorRequest({ scriptId: null, initial: EMPTY_PROJECT_SCRIPT_INPUT });
121 };
122​
123 const openEditDialog = (script: ProjectScript) => {
124 onRequestMenuClose?.();
125 setActionsMenuOpen({ presentation, scripts: false, imports: false });
126 setEditorRequest(editorRequestForScript(script, keybindings));
127 };
128​
129 const submitScript = (scriptId: string | null, input: NewProjectScriptInput) =>
130 scriptId === null ? onAddScript(input) : onUpdateScript(scriptId, input);
131​
132 const importFileScript = async (fileScript: T3ProjectFileScript) => {
133 const payload: NewProjectScriptInput = {
134 name: fileScript.name,
135 command: fileScript.command,
136 icon: fileScript.icon ?? "play",
137 runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false,
138 waitForSetup: fileScript.runOnWorktreeCreate === true && fileScript.async === false,
139 runOnSettle: fileScript.runOnSettle ?? false,
140 stopOnSettle: fileScript.stopOnSettle ?? false,
141 ...(readEnvironmentScope(environmentId, AuthSettingsWriteScope) ? { keybinding: null } : {}),
142 previewUrl: fileScript.previewUrl ?? null,
143 autoOpenPreview: fileScript.previewUrl ? (fileScript.autoOpenPreview ?? false) : false,
144 };
145 const result = await onAddScript(payload);
146 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
147 const error = squashAtomCommandFailure(result);
148 setEditorRequest({
149 scriptId: null,
150 initial: payload,
151 error: error instanceof Error ? error.message : "Failed to import action.",
152 });
153 }
154 };
155​
156 const importMenuItems = importableScripts.length > 0 && (
157 <>
158 {scripts.length > 0 && <MenuSeparator />}
159 <MenuGroup>
160 <MenuGroupLabel>From t3.json</MenuGroupLabel>
161 {importableScripts.map((fileScript) => (
162 <MenuItem
163 density={presentation === "menu" ? "touch" : "default"}
164 key={`${fileScript.name} ${fileScript.command}`}
165 onClick={() => void importFileScript(fileScript)}
166 >
167 <ScriptIcon icon={fileScript.icon ?? "play"} className="size-4" />
168 <MenuItemLabel>{fileScript.name}</MenuItemLabel>
169 <MenuShortcut>
170 <DownloadIcon className="size-3.5" aria-label="Import" />
171 </MenuShortcut>
172 </MenuItem>
173 ))}
174 </MenuGroup>
175 </>
176 );
177​
178 const scriptItems = (
179 <>
180 {scripts.map((script) => {
181 const shortcutLabel = shortcutLabelForCommand(
182 keybindings,
183 commandForProjectScript(script.id),
184 );
185 return (
186 <MenuItem
187 density={presentation === "menu" ? "touch" : "default"}
188 key={script.id}
189 className="group"
190 disabled={!onRunScript}
191 onClick={() => onRunScript?.(script)}
192 >
193 <ScriptIcon icon={script.icon} className="size-4" />
194 <MenuItemLabel>{projectScriptMenuLabel(script)}</MenuItemLabel>
195 <span className="relative ms-auto flex h-6 min-w-6 items-center justify-end">
196 {shortcutLabel &&
197 (presentation === "menu" ? (
198 <MenuShortcut className="ms-0 mr-7">{shortcutLabel}</MenuShortcut>
199 ) : (
200 <span className="transition-opacity group-hover:opacity-0 group-focus-visible:opacity-0">
201 <MenuShortcut className="ms-0">{shortcutLabel}</MenuShortcut>
202 </span>
203 ))}
204 <span
205 className={`absolute right-0 top-1/2 flex -translate-y-1/2 ${presentation === "menu" ? "" : "opacity-0 pointer-events-none transition-opacity group-hover:opacity-100 group-hover:pointer-events-auto group-focus-visible:opacity-100 group-focus-visible:pointer-events-auto"}`}
206 >
207 <Button
208 type="button"
209 variant="ghost"
210 size="icon-xs"
211 className="size-6"
212 aria-label={`Edit ${script.name}`}
213 onPointerDown={(event) => {
214 event.preventDefault();
215 event.stopPropagation();
216 }}
217 onClick={(event) => {
218 event.preventDefault();
219 event.stopPropagation();
220 openEditDialog(script);
221 }}
222 >
223 <SettingsIcon className="size-3.5" />
224 </Button>
225 </span>
226 </span>
227 </MenuItem>
228 );
229 })}
230 {importMenuItems}
231 <MenuItem density={presentation === "menu" ? "touch" : "default"} onClick={openAddDialog}>
232 <PlusIcon className="size-4" />
233 <MenuItemLabel>{isPanel ? "Add project script" : "Add action"}</MenuItemLabel>
234 </MenuItem>
235 </>
236 );
237​
238 return (
239 <>
240 {presentation === "menu" ? (
241 <>
242 {primaryScript && (
243 <MenuItem
244 density={presentation === "menu" ? "touch" : "default"}
245 disabled={!onRunScript}
246 onClick={() => onRunScript?.(primaryScript)}
247 >
248 <ScriptIcon icon={primaryScript.icon} className="size-4" />
249 <MenuItemLabel>Run {primaryScript.name}</MenuItemLabel>
250 <MenuShortcut>
251 {shortcutLabelForCommand(keybindings, commandForProjectScript(primaryScript.id))}
252 </MenuShortcut>
253 </MenuItem>
254 )}
255 {scripts.length > 0 || importableScripts.length > 0 ? (
256 <MenuSub
257 open={actionsMenuOpen.scripts}
258 onOpenChange={(open) =>
259 setActionsMenuOpen({ presentation, scripts: open, imports: false })
260 }
261 >
262 <MenuSubTrigger density="touch">
263 <ScriptIcon icon="play" className="size-4" />
264 <MenuItemLabel>Project actions</MenuItemLabel>
265 </MenuSubTrigger>
266 <MenuSubPopup>{scriptItems}</MenuSubPopup>
267 </MenuSub>
268 ) : (
269 <MenuItem
270 density={presentation === "menu" ? "touch" : "default"}
271 onClick={openAddDialog}
272 >
273 <PlusIcon className="size-4" />
274 <MenuItemLabel>Add project action…</MenuItemLabel>
275 </MenuItem>
276 )}
277 </>
278 ) : primaryScript ? (
279 <ActionGroup
280 role="group"
281 aria-label="Project scripts"
282 {...(isPanel
283 ? { className: THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS, ref: panelAnchorRef }
284 : {})}
285 >
286 <Tooltip>
287 <TooltipTrigger
288 render={
289 <ThreadDetailsControl
290 size="xs"
291 variant={isPanel ? "ghost" : "outline"}
292 part="primary"
293 panel={isPanel}
294 className={isPanel ? undefined : "w-7 sm:w-6 @3xl/header-actions:w-auto!"}
295 aria-label={`Run ${primaryScript.name}`}
296 // Tooltip replaces the button data-slot, so toolbar theming needs a separate hook.
297 data-toolbar-control=""
298 aria-disabled={!onRunScript || undefined}
299 onClick={() => onRunScript?.(primaryScript)}
300 />
301 }
302 >
303 <ScriptIcon
304 icon={primaryScript.icon}
305 {...(isPanel ? { className: THREAD_DETAILS_PANEL_ICON_CLASS } : {})}
306 />
307 <span
308 className={cn(
309 "sr-only @3xl/header-actions:not-sr-only @3xl/header-actions:ml-0.5",
310 isPanel && cn("not-sr-only ml-0 truncate", THREAD_DETAILS_PANEL_LABEL_CLASS),
311 )}
312 >
313 {primaryScript.name}
314 </span>
315 </TooltipTrigger>
316 <TooltipPopup side="top">
317 {onRunScript
318 ? `Run ${primaryScript.name}`
319 : "Pair this client again with permission to run terminal commands."}
320 </TooltipPopup>
321 </Tooltip>
322 {isPanel ? (
323 <span aria-hidden="true" className={THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS} />
324 ) : (
325 <GroupSeparator className="hidden @3xl/header-actions:block" />
326 )}
327 <Menu
328 open={actionsMenuOpen.scripts}
329 onOpenChange={(open) =>
330 setActionsMenuOpen({ presentation, scripts: open, imports: false })
331 }
332 >
333 <Tooltip>
334 <TooltipTrigger
335 render={
336 <MenuTrigger
337 render={
338 <ThreadDetailsControl
339 size={isPanel ? "sm" : "icon-xs"}
340 variant={isPanel ? "ghost" : "outline"}
341 part="secondary"
342 panel={isPanel}
343 aria-label="Script actions"
344 data-toolbar-control=""
345 />
346 }
347 />
348 }
349 >
350 <ChevronDownIcon
351 className={isPanel ? THREAD_DETAILS_PANEL_CHEVRON_CLASS : "size-4"}
352 />
353 </TooltipTrigger>
354 <TooltipPopup side="top">Script actions</TooltipPopup>
355 </Tooltip>
356 <MenuPopup
357 align="end"
358 {...(isPanel ? { anchor: panelAnchorRef } : {})}
359 className={isPanel ? "w-(--anchor-width)" : undefined}
360 >
361 {scriptItems}
362 </MenuPopup>
363 </Menu>
364 </ActionGroup>
365 ) : scripts.length > 0 || importableScripts.length > 0 ? (
366 isPanel ? (
367 <div
368 role="group"
369 aria-label="Project actions"
370 className={THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS}
371 ref={panelAnchorRef}
372 >
373 <ThreadDetailsControl
374 size="sm"
375 variant="ghost"
376 part="primary"
377 aria-label="Project actions"
378 onClick={() => setActionsMenuOpen({ presentation, scripts: false, imports: true })}
379 >
380 <WrenchIcon className={THREAD_DETAILS_PANEL_ICON_CLASS} />
381 <span className={cn("min-w-0 truncate", THREAD_DETAILS_PANEL_LABEL_CLASS)}>
382 Actions
383 </span>
384 </ThreadDetailsControl>
385 <span aria-hidden="true" className={THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS} />
386 <Menu
387 highlightItemOnHover={false}
388 open={actionsMenuOpen.imports}
389 onOpenChange={(open) =>
390 setActionsMenuOpen({ presentation, scripts: false, imports: open })
391 }
392 >
393 <MenuTrigger
394 render={
395 <ThreadDetailsControl
396 size="sm"
397 variant="ghost"
398 part="secondary"
399 aria-label="Choose project action"
400 />
401 }
402 >
403 <ChevronDownIcon className={THREAD_DETAILS_PANEL_CHEVRON_CLASS} />
404 </MenuTrigger>
405 <MenuPopup align="end" anchor={panelAnchorRef} className="w-(--anchor-width)">
406 {scriptItems}
407 </MenuPopup>
408 </Menu>
409 </div>
410 ) : (
411 <Menu
412 highlightItemOnHover={false}
413 open={actionsMenuOpen.imports}
414 onOpenChange={(open) =>
415 setActionsMenuOpen({ presentation, scripts: false, imports: open })
416 }
417 >
418 <MenuTrigger
419 render={<Button size="xs" variant="outline" aria-label="Project actions" />}
420 >
421 <WrenchIcon className="size-3.5" />
422 <span className="sr-only @3xl/header-actions:not-sr-only @3xl/header-actions:ml-0.5">
423 Actions
424 </span>
425 <ChevronDownIcon className="size-3.5" />
426 </MenuTrigger>
427 <MenuPopup align="end">{scriptItems}</MenuPopup>
428 </Menu>
429 )
430 ) : (
431 <Tooltip>
432 <TooltipTrigger
433 render={
434 <ThreadDetailsControl
435 size="xs"
436 variant={isPanel ? "ghost" : "outline"}
437 part="row"
438 panel={isPanel}
439 className={isPanel ? undefined : "w-7 sm:w-6 @3xl/header-actions:w-auto!"}
440 aria-label={isPanel ? "Add project script" : "Add action"}
441 data-toolbar-control=""
442 onClick={openAddDialog}
443 />
444 }
445 >
446 <PlusIcon className="size-3.5" />
447 <span
448 className={cn(
449 "sr-only @3xl/header-actions:not-sr-only @3xl/header-actions:ml-0.5",
450 isPanel && cn("not-sr-only ml-0.5", THREAD_DETAILS_PANEL_LABEL_CLASS),
451 )}
452 >
453 {isPanel ? "Add project script" : "Add action"}
454 </span>
455 </TooltipTrigger>
456 <TooltipPopup side="top">{isPanel ? "Add project script" : "Add action"}</TooltipPopup>
457 </Tooltip>
458 )}
459​
460 <ProjectScriptEditorDialog
461 environmentId={environmentId}
462 request={editorRequest}
463 scripts={scripts}
464 onSubmit={submitScript}
465 onDelete={(scriptId) => void onDeleteScript(scriptId)}
466 onClose={() => setEditorRequest(null)}
467 />
468 </>
469 );