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 @@
11 import type {
22 ProjectScript,
3− ProjectScriptIcon,
43 ResolvedKeybindingsConfig,
54 T3ProjectFileScript,
65 } from "@t3tools/contracts";
76 import {
87 isAtomCommandInterrupted,
98 squashAtomCommandFailure,
10− type AtomCommandResult,
119 } from "@t3tools/client-runtime/state/runtime";
12−import {
13− BugIcon,
14− ChevronDownIcon,
15− DownloadIcon,
16− FlaskConicalIcon,
17− HammerIcon,
18− ListChecksIcon,
19− PlayIcon,
20− PlusIcon,
21− SettingsIcon,
22− WrenchIcon,
23−} from "lucide-react";
24−import React, { type FormEvent, type KeyboardEvent, useCallback, useMemo, useState } from "react";
10+import { ChevronDownIcon, DownloadIcon, PlusIcon, SettingsIcon } from "lucide-react";
11+import { useCallback, useMemo, useState } from "react";
2512
26−import {
27− keybindingValueForCommand,
28− decodeProjectScriptKeybindingRule,
29−} from "~/lib/projectScriptKeybindings";
30−import { keybindingFromKeyboardEvent } from "~/components/settings/KeybindingsSettings.logic";
31−import {
32− commandForProjectScript,
33− nextProjectScriptId,
34− primaryProjectScript,
35−} from "~/projectScripts";
13+import { commandForProjectScript, primaryProjectScript } from "~/projectScripts";
3614 import { shortcutLabelForCommand } from "~/keybindings";
3715 import {
38− AlertDialog,
39− AlertDialogClose,
40− AlertDialogDescription,
41− AlertDialogFooter,
42− AlertDialogHeader,
43− AlertDialogPopup,
44− AlertDialogTitle,
45−} from "./ui/alert-dialog";
16+ EMPTY_PROJECT_SCRIPT_INPUT,
17+ editorRequestForScript,
18+ ProjectScriptEditorDialog,
19+ ScriptIcon,
20+ type NewProjectScriptInput,
21+ type ProjectScriptActionResult,
22+ type ProjectScriptEditorRequest,
23+} from "./projectScriptEditor";
4624 import { Button } from "./ui/button";
47−import {
48− Dialog,
49− DialogDescription,
50− DialogFooter,
51− DialogHeader,
52− DialogPanel,
53− DialogPopup,
54− DialogTitle,
55−} from "./ui/dialog";
5625 import { Group, GroupSeparator } from "./ui/group";
57−import { Input } from "./ui/input";
58−import { Label } from "./ui/label";
5926 import {
6027 Menu,
6128 MenuGroup,
@@ -66,48 +33,9 @@ import { · 4 unmodified lines
6633 MenuShortcut,
6734 MenuTrigger,
6835 } from "./ui/menu";
69−import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";
70−import { Switch } from "./ui/switch";
71−import { Textarea } from "./ui/textarea";
7236 import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
7337
74−const SCRIPT_ICONS: Array<{ id: ProjectScriptIcon; label: string }> = [
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−
83−function 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−
98−export 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−
110−export type ProjectScriptActionResult = AtomCommandResult<void, unknown>;
38+export type { NewProjectScriptInput, ProjectScriptActionResult };
11139
11240 const NO_FILE_SCRIPTS: ReadonlyArray<T3ProjectFileScript> = [];
11341
@@ -136,23 +64,11 @@ export default function ProjectScriptsControl({ · 22 unmodified lines
13664 onUpdateScript,
13765 onDeleteScript,
13866 }: ProjectScriptsControlProps) {
139− const addScriptFormId = React.useId();
140− const [editingScriptId, setEditingScriptId] = useState<string | null>(null);
14167 const [actionsMenuOpen, setActionsMenuOpen] = useState({
14268 scripts: false,
14369 imports: false,
14470 });
145− const [dialogOpen, setDialogOpen] = useState(false);
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);
71+ const [editorRequest, setEditorRequest] = useState<ProjectScriptEditorRequest | null>(null);
15672
15773 const primaryScript = useMemo(() => {
15874 if (preferredScriptId) {
@@ -173,112 +89,23 @@ export default function ProjectScriptsControl({ · 14 unmodified lines
17389 ),
17490 [fileScripts, scripts],
17591 );
176− const isEditing = editingScriptId !== null;
17792 const dropdownItemClassName =
17893 "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";
17994
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−
24795 const openAddDialog = () => {
248− setEditingScriptId(null);
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);
96+ setEditorRequest({ scriptId: null, initial: EMPTY_PROJECT_SCRIPT_INPUT });
25997 };
26098
26199 const openEditDialog = (script: ProjectScript) => {
262100 setActionsMenuOpen({ scripts: false, imports: false });
263− setEditingScriptId(script.id);
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);
101+ setEditorRequest(editorRequestForScript(script, keybindings));
274102 };
275103
276− const confirmDeleteScript = useCallback(() => {
277− if (!editingScriptId) return;
278− setDeleteConfirmOpen(false);
279− setDialogOpen(false);
280− void onDeleteScript(editingScriptId);
281− }, [editingScriptId, onDeleteScript]);
104+ const submitScript = useCallback(
105+ (scriptId: string | null, input: NewProjectScriptInput) =>
106+ scriptId === null ? onAddScript(input) : onUpdateScript(scriptId, input),
107+ [onAddScript, onUpdateScript],
108+ );
282109
283110 const importFileScript = async (fileScript: T3ProjectFileScript) => {
284111 const payload: NewProjectScriptInput = {
@@ -295,17 +122,11 @@ export default function ProjectScriptsControl({ · 10 unmodified lines
295122 // Surface the failure through the regular add dialog, prefilled so the
296123 // user can adjust and retry.
297124 const error = squashAtomCommandFailure(result);
298− setEditingScriptId(null);
299− setName(payload.name);
300− setCommand(payload.command);
301− setIcon(payload.icon);
302− setIconPickerOpen(false);
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);
125+ setEditorRequest({
126+ scriptId: null,
127+ initial: payload,
128+ error: error instanceof Error ? error.message : "Failed to import action.",
129+ });
309130 }
310131 };
311132
@@ -466,184 +287,13 @@ export default function ProjectScriptsControl({ · 154 unmodified lines
466287 </Tooltip>
467288 )}
468289
469− <Dialog
470− onOpenChange={(open) => {
471− setDialogOpen(open);
472− if (!open) {
473− setIconPickerOpen(false);
474− }
475− }}
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>
290+ <ProjectScriptEditorDialog
291+ request={editorRequest}
292+ scripts={scripts}
293+ onSubmit={submitScript}
294+ onDelete={(scriptId) => void onDeleteScript(scriptId)}
295+ onClose={() => setEditorRequest(null)}
296+ />
647297 </>
648298 );
649299 }