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 @@
1import type {
2 ProjectScript,
3 ProjectScriptIcon,
4 ResolvedKeybindingsConfig,
5} from "@t3tools/contracts";
6import {
7 ChevronDownIcon,
8 FlaskConicalIcon,
9 HammerIcon,
10 ListChecksIcon,
11 PlayIcon,
12 PlusIcon,
13 WrenchIcon,
14} from "lucide-react";
15import { type FormEvent, useMemo, useState } from "react";
16
17import {
18 commandForProjectScript,
19 nextProjectScriptId,
20 primaryProjectScript,
21} from "~/projectScripts";
22import { shortcutLabelForCommand } from "~/keybindings";
23import { Button } from "./ui/button";
24import {
25 Dialog,
26 DialogDescription,
27 DialogFooter,
28 DialogHeader,
29 DialogPanel,
30 DialogPopup,
31 DialogTitle,
32} from "./ui/dialog";
33import { Group, GroupSeparator } from "./ui/group";
34import { Input } from "./ui/input";
35import { Label } from "./ui/label";
36import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu";
37import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";
38import { Switch } from "./ui/switch";
39import { Textarea } from "./ui/textarea";
40
41const 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
49function 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
63export interface NewProjectScriptInput {
64 name: string;
65 command: string;
66 icon: ProjectScriptIcon;
67 runOnWorktreeCreate: boolean;
68}
69
70interface ProjectScriptsControlProps {
71 scripts: ProjectScript[];
72 keybindings: ResolvedKeybindingsConfig;
73 onRunScript: (script: ProjectScript) => void;
74 onAddScript: (input: NewProjectScriptInput) => Promise<void> | void;
75}
76
77export 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