History

Add project script actions with run shortcuts and persistence

- add project-scoped script actions in the chat toolbar, including setup-on-worktree-create - support dynamic `script.{id}.run` keybindings and command resolution - persist and sync project scripts across web state, contracts, WebSocket API, and server registry - add backend and frontend tests for script helpers, persistence, and registry behavior

Julius Marminge committed 9 months ago

Commit 3537d471 parent 9e877f525 files · +1068 −25Browse files

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

Unified · Split

apps/web/src/components/ProjectScriptsControl.tsx+259−0
@@ -0,0 +1,259 @@
1+import type {
2+ ProjectScript,
3+ ProjectScriptIcon,
4+ ResolvedKeybindingsConfig,
5+} from "@t3tools/contracts";
6+import {
7+ ChevronDownIcon,
8+ FlaskConicalIcon,
9+ HammerIcon,
10+ ListChecksIcon,
11+ PlayIcon,
12+ PlusIcon,
13+ WrenchIcon,
14+} from "lucide-react";
15+import { type FormEvent, useMemo, useState } from "react";
16+
17+import {
18+ commandForProjectScript,
19+ nextProjectScriptId,
20+ primaryProjectScript,
21+} from "~/projectScripts";
22+import { shortcutLabelForCommand } from "~/keybindings";
23+import { Button } from "./ui/button";
24+import {
25+ Dialog,
26+ DialogDescription,
27+ DialogFooter,
28+ DialogHeader,
29+ DialogPanel,
30+ DialogPopup,
31+ DialogTitle,
32+} from "./ui/dialog";
33+import { Group, GroupSeparator } from "./ui/group";
34+import { Input } from "./ui/input";
35+import { Label } from "./ui/label";
36+import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu";
37+import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";
38+import { Switch } from "./ui/switch";
39+import { Textarea } from "./ui/textarea";
40+
41+const SCRIPT_ICONS: Array<{ id: ProjectScriptIcon; label: string }> = [
42+ { id: "play", label: "Play" },
43+ { id: "test", label: "Test" },
44+ { id: "lint", label: "Lint" },
45+ { id: "configure", label: "Configure" },
46+ { id: "build", label: "Build" },
47+];
48+
49+function ScriptIcon({
50+ icon,
51+ className = "size-3.5",
52+}: {
53+ icon: ProjectScriptIcon;
54+ className?: string;
55+}) {
56+ if (icon === "test") return <FlaskConicalIcon className={className} />;
57+ if (icon === "lint") return <ListChecksIcon className={className} />;
58+ if (icon === "configure") return <WrenchIcon className={className} />;
59+ if (icon === "build") return <HammerIcon className={className} />;
60+ return <PlayIcon className={className} />;
61+}
62+
63+export interface NewProjectScriptInput {
64+ name: string;
65+ command: string;
66+ icon: ProjectScriptIcon;
67+ runOnWorktreeCreate: boolean;
68+}
69+
70+interface ProjectScriptsControlProps {
71+ scripts: ProjectScript[];
72+ keybindings: ResolvedKeybindingsConfig;
73+ onRunScript: (script: ProjectScript) => void;
74+ onAddScript: (input: NewProjectScriptInput) => Promise<void> | void;
75+}
76+
77+export default function ProjectScriptsControl({
78+ scripts,
79+ keybindings,
80+ onRunScript,
81+ onAddScript,
82+}: ProjectScriptsControlProps) {
83+ const [dialogOpen, setDialogOpen] = useState(false);
84+ const [name, setName] = useState("");
85+ const [command, setCommand] = useState("");
86+ const [icon, setIcon] = useState<ProjectScriptIcon>("play");
87+ const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false);
88+ const [validationError, setValidationError] = useState<string | null>(null);
89+
90+ const primaryScript = useMemo(() => primaryProjectScript(scripts), [scripts]);
91+ const keybindingCommandPreview = useMemo(
92+ () =>
93+ commandForProjectScript(
94+ nextProjectScriptId(
95+ name.length > 0 ? name : "script",
96+ scripts.map((script) => script.id),
97+ ),
98+ ),
99+ [name, scripts],
100+ );
101+
102+ const submitAddScript = async (event: FormEvent) => {
103+ event.preventDefault();
104+ const trimmedName = name.trim();
105+ const trimmedCommand = command.trim();
106+ if (trimmedName.length === 0) {
107+ setValidationError("Name is required.");
108+ return;
109+ }
110+ if (trimmedCommand.length === 0) {
111+ setValidationError("Command is required.");
112+ return;
113+ }
114+
115+ setValidationError(null);
116+ try {
117+ await onAddScript({
118+ name: trimmedName,
119+ command: trimmedCommand,
120+ icon,
121+ runOnWorktreeCreate,
122+ });
123+ setDialogOpen(false);
124+ setName("");
125+ setCommand("");
126+ setIcon("play");
127+ setRunOnWorktreeCreate(false);
128+ } catch (error) {
129+ setValidationError(error instanceof Error ? error.message : "Failed to save action.");
130+ }
131+ };
132+
133+ const openDialog = () => {
134+ setValidationError(null);
135+ setDialogOpen(true);
136+ };
137+
138+ return (
139+ <>
140+ {primaryScript ? (
141+ <Group aria-label="Project scripts">
142+ <Button
143+ size="xs"
144+ variant="outline"
145+ onClick={() => onRunScript(primaryScript)}
146+ title={`Run ${primaryScript.name}`}
147+ >
148+ <ScriptIcon icon={primaryScript.icon} />
149+ <span>{primaryScript.name}</span>
150+ </Button>
151+ <GroupSeparator />
152+ <Menu>
153+ <MenuTrigger
154+ render={<Button size="icon-xs" variant="outline" aria-label="Script actions" />}
155+ >
156+ <ChevronDownIcon className="size-4" />
157+ </MenuTrigger>
158+ <MenuPopup align="end">
159+ {scripts.map((script) => {
160+ const shortcutLabel = shortcutLabelForCommand(
161+ keybindings,
162+ commandForProjectScript(script.id),
163+ );
164+ return (
165+ <MenuItem key={script.id} onClick={() => onRunScript(script)}>
166+ <ScriptIcon icon={script.icon} className="size-4" />
167+ {script.runOnWorktreeCreate ? `${script.name} (Setup)` : script.name}
168+ {shortcutLabel && <MenuShortcut>{shortcutLabel}</MenuShortcut>}
169+ </MenuItem>
170+ );
171+ })}
172+ <MenuItem onClick={openDialog}>
173+ <PlusIcon className="size-4" />
174+ Add action
175+ </MenuItem>
176+ </MenuPopup>
177+ </Menu>
178+ </Group>
179+ ) : (
180+ <Button size="xs" variant="outline" onClick={openDialog}>
181+ <PlusIcon className="size-3.5" />
182+ Add action
183+ </Button>
184+ )}
185+
186+ <Dialog onOpenChange={setDialogOpen} open={dialogOpen}>
187+ <DialogPopup>
188+ <form onSubmit={submitAddScript}>
189+ <DialogHeader>
190+ <DialogTitle>Add Action</DialogTitle>
191+ <DialogDescription>
192+ Actions are project-scoped commands you can run from the top bar or keybindings.
193+ </DialogDescription>
194+ </DialogHeader>
195+ <DialogPanel className="space-y-4">
196+ <div className="space-y-1.5">
197+ <Label htmlFor="script-name">Name</Label>
198+ <Input
199+ id="script-name"
200+ placeholder="Test"
201+ value={name}
202+ onChange={(event) => setName(event.target.value)}
203+ />
204+ <p className="text-xs text-muted-foreground">
205+ Keybinding command: <code>{keybindingCommandPreview}</code>
206+ </p>
207+ </div>
208+ <div className="space-y-1.5">
209+ <Label htmlFor="script-command">Command</Label>
210+ <Textarea
211+ id="script-command"
212+ placeholder="bun test"
213+ value={command}
214+ onChange={(event) => setCommand(event.target.value)}
215+ />
216+ </div>
217+ <div className="space-y-1.5">
218+ <Label>Icon</Label>
219+ <Select
220+ items={SCRIPT_ICONS.map((entry) => ({ label: entry.label, value: entry.id }))}
221+ value={icon}
222+ onValueChange={(value) => {
223+ if (!value) return;
224+ setIcon(value as ProjectScriptIcon);
225+ }}
226+ >
227+ <SelectTrigger>
228+ <SelectValue />
229+ </SelectTrigger>
230+ <SelectPopup>
231+ {SCRIPT_ICONS.map((entry) => (
232+ <SelectItem key={entry.id} value={entry.id}>
233+ {entry.label}
234+ </SelectItem>
235+ ))}
236+ </SelectPopup>
237+ </Select>
238+ </div>
239+ <label className="flex items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2 text-sm">
240+ <span>Run automatically on worktree creation (Setup)</span>
241+ <Switch
242+ checked={runOnWorktreeCreate}
243+ onCheckedChange={(checked) => setRunOnWorktreeCreate(Boolean(checked))}
244+ />
245+ </label>
246+ {validationError && <p className="text-sm text-destructive">{validationError}</p>}
247+ </DialogPanel>
248+ <DialogFooter>
249+ <Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
250+ Cancel
251+ </Button>
252+ <Button type="submit">Save action</Button>
253+ </DialogFooter>
254+ </form>
255+ </DialogPopup>
256+ </Dialog>
257+ </>
258+ );
259+}

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t