History

feat(web): overhaul project settings into a real settings page (#5768)

Theo Browne committed 3 months ago

Commit 288d8e31 parent 5208bde11 files · +1609 −756Browse files

Showing the changes to apps/web/src/components/ProjectScriptsControl.tsx (1 of 11 files). Show all

Unified · Split

apps/web/src/components/ProjectScriptsControl.tsx+32−382
@@ -1,61 +1,28 @@
1import type {1import type {
2 ProjectScript,2 ProjectScript,
3 ProjectScriptIcon,
4 ResolvedKeybindingsConfig,3 ResolvedKeybindingsConfig,
5 T3ProjectFileScript,4 T3ProjectFileScript,
6} from "@t3tools/contracts";5} from "@t3tools/contracts";
7import {6import {
8 isAtomCommandInterrupted,7 isAtomCommandInterrupted,
9 squashAtomCommandFailure,8 squashAtomCommandFailure,
10 type AtomCommandResult,
11} from "@t3tools/client-runtime/state/runtime";9} from "@t3tools/client-runtime/state/runtime";
12import {10import { ChevronDownIcon, DownloadIcon, PlusIcon, SettingsIcon } from "lucide-react";
13 BugIcon,11import { useCallback, useMemo, useState } from "react";
14 ChevronDownIcon,
15 DownloadIcon,
16 FlaskConicalIcon,
17 HammerIcon,
18 ListChecksIcon,
19 PlayIcon,
20 PlusIcon,
21 SettingsIcon,
22 WrenchIcon,
23} from "lucide-react";
24import React, { type FormEvent, type KeyboardEvent, useCallback, useMemo, useState } from "react";
25 12
26import {13import { commandForProjectScript, primaryProjectScript } from "~/projectScripts";
27 keybindingValueForCommand,
28 decodeProjectScriptKeybindingRule,
29} from "~/lib/projectScriptKeybindings";
30import { keybindingFromKeyboardEvent } from "~/components/settings/KeybindingsSettings.logic";
31import {
32 commandForProjectScript,
33 nextProjectScriptId,
34 primaryProjectScript,
35} from "~/projectScripts";
36import { shortcutLabelForCommand } from "~/keybindings";14import { shortcutLabelForCommand } from "~/keybindings";
37import {15import {
38 AlertDialog,16 EMPTY_PROJECT_SCRIPT_INPUT,
39 AlertDialogClose,17 editorRequestForScript,
40 AlertDialogDescription,18 ProjectScriptEditorDialog,
41 AlertDialogFooter,19 ScriptIcon,
42 AlertDialogHeader,20 type NewProjectScriptInput,
43 AlertDialogPopup,21 type ProjectScriptActionResult,
44 AlertDialogTitle,22 type ProjectScriptEditorRequest,
45} from "./ui/alert-dialog";23} from "./projectScriptEditor";
46import { Button } from "./ui/button";24import { Button } from "./ui/button";
47import {
48 Dialog,
49 DialogDescription,
50 DialogFooter,
51 DialogHeader,
52 DialogPanel,
53 DialogPopup,
54 DialogTitle,
55} from "./ui/dialog";
56import { Group, GroupSeparator } from "./ui/group";25import { Group, GroupSeparator } from "./ui/group";
57import { Input } from "./ui/input";
58import { Label } from "./ui/label";
59import {26import {
60 Menu,27 Menu,
61 MenuGroup,28 MenuGroup,
@@ -66,48 +33,9 @@ import { · 4 unmodified lines
66 MenuShortcut,33 MenuShortcut,
67 MenuTrigger,34 MenuTrigger,
68} from "./ui/menu";35} from "./ui/menu";
69import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";
70import { Switch } from "./ui/switch";
71import { Textarea } from "./ui/textarea";
72import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";36import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
73 37
74const SCRIPT_ICONS: Array<{ id: ProjectScriptIcon; label: string }> = [38export type { NewProjectScriptInput, ProjectScriptActionResult };
75 { id: "play", label: "Play" },
76 { id: "test", label: "Test" },
77 { id: "lint", label: "Lint" },
78 { id: "configure", label: "Configure" },
79 { id: "build", label: "Build" },
80 { id: "debug", label: "Debug" },
81];
82
83function ScriptIcon({
84 icon,
85 className = "size-3.5",
86}: {
87 icon: ProjectScriptIcon;
88 className?: string;
89}) {
90 if (icon === "test") return <FlaskConicalIcon className={className} />;
91 if (icon === "lint") return <ListChecksIcon className={className} />;
92 if (icon === "configure") return <WrenchIcon className={className} />;
93 if (icon === "build") return <HammerIcon className={className} />;
94 if (icon === "debug") return <BugIcon className={className} />;
95 return <PlayIcon className={className} />;
96}
97
98export interface NewProjectScriptInput {
99 name: string;
100 command: string;
101 icon: ProjectScriptIcon;
102 runOnWorktreeCreate: boolean;
103 keybinding: string | null;
104 /** Optional URL to open in the in-app preview when this script runs. */
105 previewUrl: string | null;
106 /** When true, automatically open the preview panel pointed at `previewUrl`. */
107 autoOpenPreview: boolean;
108}
109
110export type ProjectScriptActionResult = AtomCommandResult<void, unknown>;
111 39
112const NO_FILE_SCRIPTS: ReadonlyArray<T3ProjectFileScript> = [];40const NO_FILE_SCRIPTS: ReadonlyArray<T3ProjectFileScript> = [];
113 41
@@ -136,23 +64,11 @@ export default function ProjectScriptsControl({ · 22 unmodified lines
136 onUpdateScript,64 onUpdateScript,
137 onDeleteScript,65 onDeleteScript,
138}: ProjectScriptsControlProps) {66}: ProjectScriptsControlProps) {
139 const addScriptFormId = React.useId();
140 const [editingScriptId, setEditingScriptId] = useState<string | null>(null);
141 const [actionsMenuOpen, setActionsMenuOpen] = useState({67 const [actionsMenuOpen, setActionsMenuOpen] = useState({
142 scripts: false,68 scripts: false,
143 imports: false,69 imports: false,
144 });70 });
145 const [dialogOpen, setDialogOpen] = useState(false);71 const [editorRequest, setEditorRequest] = useState<ProjectScriptEditorRequest | null>(null);
146 const [name, setName] = useState("");
147 const [command, setCommand] = useState("");
148 const [icon, setIcon] = useState<ProjectScriptIcon>("play");
149 const [iconPickerOpen, setIconPickerOpen] = useState(false);
150 const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false);
151 const [keybinding, setKeybinding] = useState("");
152 const [previewUrl, setPreviewUrl] = useState("");
153 const [autoOpenPreview, setAutoOpenPreview] = useState(false);
154 const [validationError, setValidationError] = useState<string | null>(null);
155 const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
156 72
157 const primaryScript = useMemo(() => {73 const primaryScript = useMemo(() => {
158 if (preferredScriptId) {74 if (preferredScriptId) {
@@ -173,112 +89,23 @@ export default function ProjectScriptsControl({ · 14 unmodified lines
173 ),89 ),
174 [fileScripts, scripts],90 [fileScripts, scripts],
175 );91 );
176 const isEditing = editingScriptId !== null;
177 const dropdownItemClassName =92 const dropdownItemClassName =
178 "data-highlighted:bg-transparent data-highlighted:text-foreground hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground data-highlighted:hover:bg-accent data-highlighted:hover:text-accent-foreground data-highlighted:focus-visible:bg-accent data-highlighted:focus-visible:text-accent-foreground";93 "data-highlighted:bg-transparent data-highlighted:text-foreground hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground data-highlighted:hover:bg-accent data-highlighted:hover:text-accent-foreground data-highlighted:focus-visible:bg-accent data-highlighted:focus-visible:text-accent-foreground";
179 94
180 const captureKeybinding = (event: KeyboardEvent<HTMLInputElement>) => {
181 if (event.key === "Tab") return;
182 event.preventDefault();
183 if (event.key === "Backspace" || event.key === "Delete") {
184 setKeybinding("");
185 return;
186 }
187 const next = keybindingFromKeyboardEvent(event, navigator.platform);
188 if (!next) return;
189 setKeybinding(next);
190 };
191
192 const submitAddScript = async (event: FormEvent) => {
193 event.preventDefault();
194 const trimmedName = name.trim();
195 const trimmedCommand = command.trim();
196 if (trimmedName.length === 0) {
197 setValidationError("Name is required.");
198 return;
199 }
200 if (trimmedCommand.length === 0) {
201 setValidationError("Command is required.");
202 return;
203 }
204
205 setValidationError(null);
206 let payload: NewProjectScriptInput;
207 try {
208 const scriptIdForValidation =
209 editingScriptId ??
210 nextProjectScriptId(
211 trimmedName,
212 scripts.map((script) => script.id),
213 );
214 const keybindingRule = decodeProjectScriptKeybindingRule({
215 keybinding,
216 command: commandForProjectScript(scriptIdForValidation),
217 });
218 const trimmedPreviewUrl = previewUrl.trim();
219 payload = {
220 name: trimmedName,
221 command: trimmedCommand,
222 icon,
223 runOnWorktreeCreate,
224 keybinding: keybindingRule?.key ?? null,
225 previewUrl: trimmedPreviewUrl.length > 0 ? trimmedPreviewUrl : null,
226 autoOpenPreview: trimmedPreviewUrl.length > 0 ? autoOpenPreview : false,
227 } satisfies NewProjectScriptInput;
228 } catch (error) {
229 setValidationError(error instanceof Error ? error.message : "Failed to save action.");
230 return;
231 }
232
233 const result = editingScriptId
234 ? await onUpdateScript(editingScriptId, payload)
235 : await onAddScript(payload);
236 if (result._tag === "Failure") {
237 if (!isAtomCommandInterrupted(result)) {
238 const error = squashAtomCommandFailure(result);
239 setValidationError(error instanceof Error ? error.message : "Failed to save action.");
240 }
241 return;
242 }
243 setDialogOpen(false);
244 setIconPickerOpen(false);
245 };
246
247 const openAddDialog = () => {95 const openAddDialog = () => {
248 setEditingScriptId(null);96 setEditorRequest({ scriptId: null, initial: EMPTY_PROJECT_SCRIPT_INPUT });
249 setName("");
250 setCommand("");
251 setIcon("play");
252 setIconPickerOpen(false);
253 setRunOnWorktreeCreate(false);
254 setKeybinding("");
255 setPreviewUrl("");
256 setAutoOpenPreview(false);
257 setValidationError(null);
258 setDialogOpen(true);
259 };97 };
260 98
261 const openEditDialog = (script: ProjectScript) => {99 const openEditDialog = (script: ProjectScript) => {
262 setActionsMenuOpen({ scripts: false, imports: false });100 setActionsMenuOpen({ scripts: false, imports: false });
263 setEditingScriptId(script.id);101 setEditorRequest(editorRequestForScript(script, keybindings));
264 setName(script.name);
265 setCommand(script.command);
266 setIcon(script.icon);
267 setIconPickerOpen(false);
268 setRunOnWorktreeCreate(script.runOnWorktreeCreate);
269 setKeybinding(keybindingValueForCommand(keybindings, commandForProjectScript(script.id)) ?? "");
270 setPreviewUrl(script.previewUrl ?? "");
271 setAutoOpenPreview(script.autoOpenPreview ?? false);
272 setValidationError(null);
273 setDialogOpen(true);
274 };102 };
275 103
276 const confirmDeleteScript = useCallback(() => {104 const submitScript = useCallback(
277 if (!editingScriptId) return;105 (scriptId: string | null, input: NewProjectScriptInput) =>
278 setDeleteConfirmOpen(false);106 scriptId === null ? onAddScript(input) : onUpdateScript(scriptId, input),
279 setDialogOpen(false);107 [onAddScript, onUpdateScript],
280 void onDeleteScript(editingScriptId);108 );
281 }, [editingScriptId, onDeleteScript]);
282 109
283 const importFileScript = async (fileScript: T3ProjectFileScript) => {110 const importFileScript = async (fileScript: T3ProjectFileScript) => {
284 const payload: NewProjectScriptInput = {111 const payload: NewProjectScriptInput = {
@@ -295,17 +122,11 @@ export default function ProjectScriptsControl({ · 10 unmodified lines
295 // Surface the failure through the regular add dialog, prefilled so the122 // Surface the failure through the regular add dialog, prefilled so the
296 // user can adjust and retry.123 // user can adjust and retry.
297 const error = squashAtomCommandFailure(result);124 const error = squashAtomCommandFailure(result);
298 setEditingScriptId(null);125 setEditorRequest({
299 setName(payload.name);126 scriptId: null,
300 setCommand(payload.command);127 initial: payload,
301 setIcon(payload.icon);128 error: error instanceof Error ? error.message : "Failed to import action.",
302 setIconPickerOpen(false);129 });
303 setRunOnWorktreeCreate(payload.runOnWorktreeCreate);
304 setKeybinding("");
305 setPreviewUrl(payload.previewUrl ?? "");
306 setAutoOpenPreview(payload.autoOpenPreview);
307 setValidationError(error instanceof Error ? error.message : "Failed to import action.");
308 setDialogOpen(true);
309 }130 }
310 };131 };
311 132
@@ -466,184 +287,13 @@ export default function ProjectScriptsControl({ · 154 unmodified lines
466 </Tooltip>287 </Tooltip>
467 )}288 )}
468 289
469 <Dialog290 <ProjectScriptEditorDialog
470 onOpenChange={(open) => {291 request={editorRequest}
471 setDialogOpen(open);292 scripts={scripts}
472 if (!open) {293 onSubmit={submitScript}
473 setIconPickerOpen(false);294 onDelete={(scriptId) => void onDeleteScript(scriptId)}
474 }295 onClose={() => setEditorRequest(null)}
475 }}296 />
476 onOpenChangeComplete={(open) => {
477 if (open) return;
478 setEditingScriptId(null);
479 setName("");
480 setCommand("");
481 setIcon("play");
482 setRunOnWorktreeCreate(false);
483 setKeybinding("");
484 setPreviewUrl("");
485 setAutoOpenPreview(false);
486 setValidationError(null);
487 }}
488 open={dialogOpen}
489 >
490 <DialogPopup>
491 <DialogHeader>
492 <DialogTitle>{isEditing ? "Edit Action" : "Add Action"}</DialogTitle>
493 <DialogDescription>
494 Actions are project-scoped commands you can run from the top bar or keybindings.
495 </DialogDescription>
496 </DialogHeader>
497 <DialogPanel>
498 <form id={addScriptFormId} className="space-y-4" onSubmit={submitAddScript}>
499 <div className="space-y-1.5">
500 <Label htmlFor="script-name">Name</Label>
501 <div className="flex items-center gap-2">
502 <Popover onOpenChange={setIconPickerOpen} open={iconPickerOpen}>
503 <PopoverTrigger
504 render={
505 <Button
506 type="button"
507 variant="outline"
508 className="size-9 shrink-0 hover:bg-popover active:bg-popover data-pressed:bg-popover data-pressed:shadow-xs/5 data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:border-transparent dark:bg-white/[0.035] dark:data-pressed:before:shadow-none"
509 aria-label="Choose icon"
510 />
511 }
512 >
513 <ScriptIcon icon={icon} className="size-4.5" />
514 </PopoverTrigger>
515 <PopoverPopup align="start">
516 <div className="grid grid-cols-3 gap-2">
517 {SCRIPT_ICONS.map((entry) => {
518 const isSelected = entry.id === icon;
519 return (
520 <button
521 key={entry.id}
522 type="button"
523 className={`relative flex flex-col items-center gap-2 rounded-md border px-2 py-2 text-xs dark:border-transparent ${
524 isSelected
525 ? "border-primary/70 bg-primary/10 dark:ring-1 dark:ring-primary/30"
526 : "border-border/70 hover:bg-accent/60 dark:bg-white/[0.035]"
527 }`}
528 onClick={() => {
529 setIcon(entry.id);
530 setIconPickerOpen(false);
531 }}
532 >
533 <ScriptIcon icon={entry.id} className="size-4" />
534 <span>{entry.label}</span>
535 </button>
536 );
537 })}
538 </div>
539 </PopoverPopup>
540 </Popover>
541 <Input
542 id="script-name"
543 autoFocus
544 placeholder="Test"
545 value={name}
546 onChange={(event) => setName(event.target.value)}
547 />
548 </div>
549 </div>
550 <div className="space-y-1.5">
551 <Label htmlFor="script-keybinding">Keybinding</Label>
552 <Input
553 id="script-keybinding"
554 placeholder="Press shortcut"
555 value={keybinding}
556 readOnly
557 onKeyDown={captureKeybinding}
558 />
559 <p className="text-xs text-muted-foreground">
560 Press a shortcut. Use <code>Backspace</code> to clear.
561 </p>
562 </div>
563 <div className="space-y-1.5">
564 <Label htmlFor="script-command">Command</Label>
565 <Textarea
566 id="script-command"
567 placeholder="bun test"
568 value={command}
569 onChange={(event) => setCommand(event.target.value)}
570 />
571 </div>
572 <div className="space-y-1.5">
573 <Label htmlFor="script-preview-url">Preview URL (optional)</Label>
574 <Input
575 id="script-preview-url"
576 placeholder="http://localhost:5173"
577 value={previewUrl}
578 onChange={(event) => setPreviewUrl(event.target.value)}
579 />
580 <p className="text-xs text-muted-foreground">
581 Open this URL in the in-app preview when this action runs.
582 </p>
583 </div>
584 <label className="flex items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2 text-sm dark:border-transparent dark:bg-white/[0.035]">
585 <span>Run automatically on worktree creation</span>
586 <Switch
587 checked={runOnWorktreeCreate}
588 onCheckedChange={(checked) => setRunOnWorktreeCreate(Boolean(checked))}
589 />
590 </label>
591 <label
592 className={`flex items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2 text-sm dark:border-transparent dark:bg-white/[0.035] ${
593 previewUrl.trim().length === 0 ? "opacity-60" : ""
594 }`}
595 >
596 <span>Open preview automatically when this action runs</span>
597 <Switch
598 checked={autoOpenPreview}
599 disabled={previewUrl.trim().length === 0}
600 onCheckedChange={(checked) => setAutoOpenPreview(Boolean(checked))}
601 />
602 </label>
603 {validationError && <p className="text-sm text-destructive">{validationError}</p>}
604 </form>
605 </DialogPanel>
606 <DialogFooter className="dark:border-transparent dark:bg-transparent">
607 {isEditing && (
608 <Button
609 type="button"
610 variant="destructive-outline"
611 className="mr-auto"
612 onClick={() => setDeleteConfirmOpen(true)}
613 >
614 Delete
615 </Button>
616 )}
617 <Button
618 type="button"
619 variant="outline"
620 onClick={() => {
621 setDialogOpen(false);
622 }}
623 >
624 Cancel
625 </Button>
626 <Button form={addScriptFormId} type="submit">
627 {isEditing ? "Save changes" : "Save action"}
628 </Button>
629 </DialogFooter>
630 </DialogPopup>
631 </Dialog>
632
633 <AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
634 <AlertDialogPopup>
635 <AlertDialogHeader>
636 <AlertDialogTitle>Delete action "{name}"?</AlertDialogTitle>
637 <AlertDialogDescription>This action cannot be undone.</AlertDialogDescription>
638 </AlertDialogHeader>
639 <AlertDialogFooter>
640 <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
641 <Button variant="destructive" onClick={confirmDeleteScript}>
642 Delete action
643 </Button>
644 </AlertDialogFooter>
645 </AlertDialogPopup>
646 </AlertDialog>
647 </>297 </>
648 );298 );
649}299}