import type { ProjectScript, ProjectScriptIcon, ResolvedKeybindingsConfig,} from "@t3tools/contracts";import { BugIcon, ChevronDownIcon, FlaskConicalIcon, HammerIcon, ListChecksIcon, PlayIcon, PlusIcon, SettingsIcon, WrenchIcon,} from "lucide-react";import React, { type FormEvent, type KeyboardEvent, useMemo, useState } from "react"; import { keybindingValueForCommand, decodeProjectScriptKeybindingRule,} from "~/lib/projectScriptKeybindings";import { commandForProjectScript, nextProjectScriptId, primaryProjectScript,} from "~/projectScripts";import { shortcutLabelForCommand } from "~/keybindings";import { isMacPlatform } from "~/lib/utils";import { Button } from "./ui/button";import { Dialog, DialogDescription, DialogFooter, DialogHeader, DialogPanel, DialogPopup, DialogTitle,} from "./ui/dialog";import { Group, GroupSeparator } from "./ui/group";import { Input } from "./ui/input";import { Label } from "./ui/label";import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu";import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";import { Switch } from "./ui/switch";import { Textarea } from "./ui/textarea"; const SCRIPT_ICONS: Array<{ id: ProjectScriptIcon; label: string }> = [ { id: "play", label: "Play" }, { id: "test", label: "Test" }, { id: "lint", label: "Lint" }, { id: "configure", label: "Configure" }, { id: "build", label: "Build" }, { id: "debug", label: "Debug" },]; function ScriptIcon({ icon, className = "size-3.5",}: { icon: ProjectScriptIcon; className?: string;}) { if (icon === "test") return <FlaskConicalIcon className={className} />; if (icon === "lint") return <ListChecksIcon className={className} />; if (icon === "configure") return <WrenchIcon className={className} />; if (icon === "build") return <HammerIcon className={className} />; if (icon === "debug") return <BugIcon className={className} />; return <PlayIcon className={className} />;} export interface NewProjectScriptInput { name: string; command: string; icon: ProjectScriptIcon; runOnWorktreeCreate: boolean; keybinding: string | null;} interface ProjectScriptsControlProps { scripts: ProjectScript[]; keybindings: ResolvedKeybindingsConfig; preferredScriptId?: string | null; onRunScript: (script: ProjectScript) => void; onAddScript: (input: NewProjectScriptInput) => Promise<void> | void; onUpdateScript: (scriptId: string, input: NewProjectScriptInput) => Promise<void> | void;} function normalizeShortcutKeyToken(key: string): string | null { const normalized = key.toLowerCase(); if ( normalized === "meta" || normalized === "control" || normalized === "ctrl" || normalized === "shift" || normalized === "alt" || normalized === "option" ) { return null; } if (normalized === " ") return "space"; if (normalized === "escape") return "esc"; if (normalized === "arrowup") return "arrowup"; if (normalized === "arrowdown") return "arrowdown"; if (normalized === "arrowleft") return "arrowleft"; if (normalized === "arrowright") return "arrowright"; if (normalized.length === 1) return normalized; if (normalized.startsWith("f") && normalized.length <= 3) return normalized; if (normalized === "enter" || normalized === "tab" || normalized === "backspace") { return normalized; } if (normalized === "delete" || normalized === "home" || normalized === "end") { return normalized; } if (normalized === "pageup" || normalized === "pagedown") return normalized; return null;} function keybindingFromEvent(event: KeyboardEvent<HTMLInputElement>): string | null { const keyToken = normalizeShortcutKeyToken(event.key); if (!keyToken) return null; const parts: string[] = []; if (isMacPlatform(navigator.platform)) { if (event.metaKey) parts.push("mod"); if (event.ctrlKey) parts.push("ctrl"); } else { if (event.ctrlKey) parts.push("mod"); if (event.metaKey) parts.push("meta"); } if (event.altKey) parts.push("alt"); if (event.shiftKey) parts.push("shift"); if (parts.length === 0) { return null; } parts.push(keyToken); return parts.join("+");} export default function ProjectScriptsControl({ scripts, keybindings, preferredScriptId = null, onRunScript, onAddScript, onUpdateScript,}: ProjectScriptsControlProps) { const addScriptFormId = React.useId(); const [editingScriptId, setEditingScriptId] = useState<string | null>(null); const [dialogOpen, setDialogOpen] = useState(false); const [name, setName] = useState(""); const [command, setCommand] = useState(""); const [icon, setIcon] = useState<ProjectScriptIcon>("play"); const [iconPickerOpen, setIconPickerOpen] = useState(false); const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false); const [keybinding, setKeybinding] = useState(""); const [validationError, setValidationError] = useState<string | null>(null); const primaryScript = useMemo(() => { if (preferredScriptId) { const preferred = scripts.find((script) => script.id === preferredScriptId); if (preferred) return preferred; } return primaryProjectScript(scripts); }, [preferredScriptId, scripts]); const isEditing = editingScriptId !== null; const dropdownItemClassName = "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"; const captureKeybinding = (event: KeyboardEvent<HTMLInputElement>) => { if (event.key === "Tab") return; event.preventDefault(); if (event.key === "Backspace" || event.key === "Delete") { setKeybinding(""); return; } const next = keybindingFromEvent(event); if (!next) return; setKeybinding(next); }; const submitAddScript = async (event: FormEvent) => { event.preventDefault(); const trimmedName = name.trim(); const trimmedCommand = command.trim(); if (trimmedName.length === 0) { setValidationError("Name is required."); return; } if (trimmedCommand.length === 0) { setValidationError("Command is required."); return; } setValidationError(null); try { const scriptIdForValidation = editingScriptId ?? nextProjectScriptId( trimmedName, scripts.map((script) => script.id), ); const keybindingRule = decodeProjectScriptKeybindingRule({ keybinding, command: commandForProjectScript(scriptIdForValidation), }); const payload = { name: trimmedName, command: trimmedCommand, icon, runOnWorktreeCreate, keybinding: keybindingRule?.key ?? null, } satisfies NewProjectScriptInput; if (editingScriptId) { await onUpdateScript(editingScriptId, payload); } else { await onAddScript(payload); } setDialogOpen(false); setIconPickerOpen(false); } catch (error) { setValidationError(error instanceof Error ? error.message : "Failed to save action."); } }; const openAddDialog = () => { setEditingScriptId(null); setName(""); setCommand(""); setIcon("play"); setIconPickerOpen(false); setRunOnWorktreeCreate(false); setKeybinding(""); setValidationError(null); setDialogOpen(true); }; const openEditDialog = (script: ProjectScript) => { setEditingScriptId(script.id); setName(script.name); setCommand(script.command); setIcon(script.icon); setIconPickerOpen(false); setRunOnWorktreeCreate(script.runOnWorktreeCreate); setKeybinding(keybindingValueForCommand(keybindings, commandForProjectScript(script.id)) ?? ""); setValidationError(null); setDialogOpen(true); }; return ( <> {primaryScript ? ( <Group aria-label="Project scripts"> <Button size="xs" variant="outline" onClick={() => onRunScript(primaryScript)} title={`Run ${primaryScript.name}`} > <ScriptIcon icon={primaryScript.icon} /> <span className="sr-only @sm/header-actions:not-sr-only @sm/header-actions:ml-0.5"> {primaryScript.name} </span> </Button> <GroupSeparator className="hidden @sm/header-actions:block" /> <Menu highlightItemOnHover={false}> <MenuTrigger render={<Button size="icon-xs" variant="outline" aria-label="Script actions" />} > <ChevronDownIcon className="size-4" /> </MenuTrigger> <MenuPopup align="end"> {scripts.map((script) => { const shortcutLabel = shortcutLabelForCommand( keybindings, commandForProjectScript(script.id), ); return ( <MenuItem key={script.id} className={`group ${dropdownItemClassName}`} onClick={() => onRunScript(script)} > <ScriptIcon icon={script.icon} className="size-4" /> <span className="truncate"> {script.runOnWorktreeCreate ? `${script.name} (setup)` : script.name} </span> <span className="relative ms-auto flex h-6 min-w-6 items-center justify-end"> {shortcutLabel && ( <MenuShortcut className="ms-0 transition-opacity group-hover:opacity-0 group-focus-visible:opacity-0"> {shortcutLabel} </MenuShortcut> )} <Button type="button" variant="ghost" size="icon-xs" className="absolute right-0 top-1/2 size-6 -translate-y-1/2 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" aria-label={`Edit ${script.name}`} onPointerDown={(event) => { event.preventDefault(); event.stopPropagation(); }} onClick={(event) => { event.preventDefault(); event.stopPropagation(); openEditDialog(script); }} > <SettingsIcon className="size-3.5" /> </Button> </span> </MenuItem> ); })} <MenuItem className={dropdownItemClassName} onClick={openAddDialog}> <PlusIcon className="size-4" /> Add action </MenuItem> </MenuPopup> </Menu> </Group> ) : ( <Button size="xs" variant="outline" onClick={openAddDialog} title="Add action"> <PlusIcon className="size-3.5" /> <span className="sr-only @sm/header-actions:not-sr-only @sm/header-actions:ml-0.5"> Add action </span> </Button> )} <Dialog onOpenChange={(open) => { setDialogOpen(open); if (!open) { setIconPickerOpen(false); } }} onOpenChangeComplete={(open) => { if (open) return; setEditingScriptId(null); setName(""); setCommand(""); setIcon("play"); setRunOnWorktreeCreate(false); setKeybinding(""); setValidationError(null); }} open={dialogOpen} > <DialogPopup> <DialogHeader> <DialogTitle>{isEditing ? "Edit Action" : "Add Action"}</DialogTitle> <DialogDescription> Actions are project-scoped commands you can run from the top bar or keybindings. </DialogDescription> </DialogHeader> <DialogPanel> <form id={addScriptFormId} className="space-y-4" onSubmit={submitAddScript}> <div className="space-y-1.5"> <Label htmlFor="script-name">Name</Label> <div className="flex items-center gap-2"> <Popover onOpenChange={setIconPickerOpen} open={iconPickerOpen}> <PopoverTrigger render={ <Button type="button" variant="outline" 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:data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)]" aria-label="Choose icon" /> } > <ScriptIcon icon={icon} className="size-4.5" /> </PopoverTrigger> <PopoverPopup align="start"> <div className="grid grid-cols-3 gap-2"> {SCRIPT_ICONS.map((entry) => { const isSelected = entry.id === icon; return ( <button key={entry.id} type="button" className={`relative flex flex-col items-center gap-2 rounded-md border px-2 py-2 text-xs ${ isSelected ? "border-primary/70 bg-primary/10" : "border-border/70 hover:bg-accent/60" }`} onClick={() => { setIcon(entry.id); setIconPickerOpen(false); }} > <ScriptIcon icon={entry.id} className="size-4" /> <span>{entry.label}</span> </button> ); })} </div> </PopoverPopup> </Popover> <Input id="script-name" placeholder="Test" value={name} onChange={(event) => setName(event.target.value)} /> </div> </div> <div className="space-y-1.5"> <Label htmlFor="script-keybinding">Keybinding</Label> <Input id="script-keybinding" placeholder="Press shortcut" value={keybinding} readOnly onKeyDown={captureKeybinding} /> <p className="text-xs text-muted-foreground"> Press a shortcut. Use <code>Backspace</code> to clear. </p> </div> <div className="space-y-1.5"> <Label htmlFor="script-command">Command</Label> <Textarea id="script-command" placeholder="bun test" value={command} onChange={(event) => setCommand(event.target.value)} /> </div> <label className="flex items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2 text-sm"> <span>Run automatically on worktree creation</span> <Switch checked={runOnWorktreeCreate} onCheckedChange={(checked) => setRunOnWorktreeCreate(Boolean(checked))} /> </label> {validationError && <p className="text-sm text-destructive">{validationError}</p>} </form> </DialogPanel> <DialogFooter> <Button type="button" variant="outline" onClick={() => { setDialogOpen(false); }} > Cancel </Button> <Button form={addScriptFormId} type="submit"> {isEditing ? "Save changes" : "Save action"} </Button> </DialogFooter> </DialogPopup> </Dialog> </> );}
import type {
ProjectScript,
ProjectScriptIcon,
ResolvedKeybindingsConfig,
} from "@t3tools/contracts";
import {
BugIcon,
ChevronDownIcon,
FlaskConicalIcon,
HammerIcon,
ListChecksIcon,
PlayIcon,
PlusIcon,
SettingsIcon,
WrenchIcon,
} from "lucide-react";
import React, { type FormEvent, type KeyboardEvent, useMemo, useState } from "react";
import {
keybindingValueForCommand,
decodeProjectScriptKeybindingRule,
} from "~/lib/projectScriptKeybindings";
import {
commandForProjectScript,
nextProjectScriptId,
primaryProjectScript,
} from "~/projectScripts";
import { shortcutLabelForCommand } from "~/keybindings";
import { isMacPlatform } from "~/lib/utils";
import { Button } from "./ui/button";
import {
Dialog,
DialogDescription,
DialogFooter,
DialogHeader,
DialogPanel,
DialogPopup,
DialogTitle,
} from "./ui/dialog";
import { Group, GroupSeparator } from "./ui/group";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu";
import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";
import { Switch } from "./ui/switch";
import { Textarea } from "./ui/textarea";
const SCRIPT_ICONS: Array<{ id: ProjectScriptIcon; label: string }> = [
{ id: "play", label: "Play" },
{ id: "test", label: "Test" },
{ id: "lint", label: "Lint" },
{ id: "configure", label: "Configure" },
{ id: "build", label: "Build" },
{ id: "debug", label: "Debug" },
];
function ScriptIcon({
icon,
className = "size-3.5",
}: {
icon: ProjectScriptIcon;
className?: string;
}) {
if (icon === "test") return <FlaskConicalIcon className={className} />;
if (icon === "lint") return <ListChecksIcon className={className} />;
if (icon === "configure") return <WrenchIcon className={className} />;
if (icon === "build") return <HammerIcon className={className} />;
if (icon === "debug") return <BugIcon className={className} />;
return <PlayIcon className={className} />;
}
export interface NewProjectScriptInput {
name: string;
command: string;
icon: ProjectScriptIcon;
runOnWorktreeCreate: boolean;
keybinding: string | null;
}
interface ProjectScriptsControlProps {
scripts: ProjectScript[];
keybindings: ResolvedKeybindingsConfig;
preferredScriptId?: string | null;
onRunScript: (script: ProjectScript) => void;
onAddScript: (input: NewProjectScriptInput) => Promise<void> | void;
onUpdateScript: (scriptId: string, input: NewProjectScriptInput) => Promise<void> | void;
}
function normalizeShortcutKeyToken(key: string): string | null {
const normalized = key.toLowerCase();
if (
normalized === "meta" ||
normalized === "control" ||
normalized === "ctrl" ||
normalized === "shift" ||
normalized === "alt" ||
normalized === "option"
) {
return null;
}
if (normalized === " ") return "space";
if (normalized === "escape") return "esc";
if (normalized === "arrowup") return "arrowup";
if (normalized === "arrowdown") return "arrowdown";
if (normalized === "arrowleft") return "arrowleft";
if (normalized === "arrowright") return "arrowright";
if (normalized.length === 1) return normalized;
if (normalized.startsWith("f") && normalized.length <= 3) return normalized;
if (normalized === "enter" || normalized === "tab" || normalized === "backspace") {
return normalized;
}
if (normalized === "delete" || normalized === "home" || normalized === "end") {
return normalized;
}
if (normalized === "pageup" || normalized === "pagedown") return normalized;
return null;
}
function keybindingFromEvent(event: KeyboardEvent<HTMLInputElement>): string | null {
const keyToken = normalizeShortcutKeyToken(event.key);
if (!keyToken) return null;
const parts: string[] = [];
if (isMacPlatform(navigator.platform)) {
if (event.metaKey) parts.push("mod");
if (event.ctrlKey) parts.push("ctrl");
} else {
if (event.ctrlKey) parts.push("mod");
if (event.metaKey) parts.push("meta");
}
if (event.altKey) parts.push("alt");
if (event.shiftKey) parts.push("shift");
if (parts.length === 0) {
return null;
}
parts.push(keyToken);
return parts.join("+");
}
export default function ProjectScriptsControl({
scripts,
keybindings,
preferredScriptId = null,
onRunScript,
onAddScript,
onUpdateScript,
}: ProjectScriptsControlProps) {
const addScriptFormId = React.useId();
const [editingScriptId, setEditingScriptId] = useState<string | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [name, setName] = useState("");
const [command, setCommand] = useState("");
const [icon, setIcon] = useState<ProjectScriptIcon>("play");
const [iconPickerOpen, setIconPickerOpen] = useState(false);
const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false);
const [keybinding, setKeybinding] = useState("");
const [validationError, setValidationError] = useState<string | null>(null);
const primaryScript = useMemo(() => {
if (preferredScriptId) {
const preferred = scripts.find((script) => script.id === preferredScriptId);
if (preferred) return preferred;
}
return primaryProjectScript(scripts);
}, [preferredScriptId, scripts]);
const isEditing = editingScriptId !== null;
const dropdownItemClassName =
"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";
const captureKeybinding = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Tab") return;
event.preventDefault();
if (event.key === "Backspace" || event.key === "Delete") {
setKeybinding("");
return;
}
const next = keybindingFromEvent(event);
if (!next) return;
setKeybinding(next);
};
const submitAddScript = async (event: FormEvent) => {
event.preventDefault();
const trimmedName = name.trim();
const trimmedCommand = command.trim();
if (trimmedName.length === 0) {
setValidationError("Name is required.");
return;
}
if (trimmedCommand.length === 0) {
setValidationError("Command is required.");
return;
}
setValidationError(null);
try {
const scriptIdForValidation =
editingScriptId ??
nextProjectScriptId(
trimmedName,
scripts.map((script) => script.id),
);
const keybindingRule = decodeProjectScriptKeybindingRule({
keybinding,
command: commandForProjectScript(scriptIdForValidation),
});
const payload = {
name: trimmedName,
command: trimmedCommand,
icon,
runOnWorktreeCreate,
keybinding: keybindingRule?.key ?? null,
} satisfies NewProjectScriptInput;
if (editingScriptId) {
await onUpdateScript(editingScriptId, payload);
} else {
await onAddScript(payload);
}
setDialogOpen(false);
setIconPickerOpen(false);
} catch (error) {
setValidationError(error instanceof Error ? error.message : "Failed to save action.");
}
};
const openAddDialog = () => {
setEditingScriptId(null);
setName("");
setCommand("");
setIcon("play");
setIconPickerOpen(false);
setRunOnWorktreeCreate(false);
setKeybinding("");
setValidationError(null);
setDialogOpen(true);
};
const openEditDialog = (script: ProjectScript) => {
setEditingScriptId(script.id);
setName(script.name);
setCommand(script.command);
setIcon(script.icon);
setIconPickerOpen(false);
setRunOnWorktreeCreate(script.runOnWorktreeCreate);
setKeybinding(keybindingValueForCommand(keybindings, commandForProjectScript(script.id)) ?? "");
setValidationError(null);
setDialogOpen(true);
};
return (
<>
{primaryScript ? (
<Group aria-label="Project scripts">
<Button
size="xs"
variant="outline"
onClick={() => onRunScript(primaryScript)}
title={`Run ${primaryScript.name}`}
>
<ScriptIcon icon={primaryScript.icon} />
<span className="sr-only @sm/header-actions:not-sr-only @sm/header-actions:ml-0.5">
{primaryScript.name}
</span>
</Button>
<GroupSeparator className="hidden @sm/header-actions:block" />
<Menu highlightItemOnHover={false}>
<MenuTrigger
render={<Button size="icon-xs" variant="outline" aria-label="Script actions" />}
>
<ChevronDownIcon className="size-4" />
</MenuTrigger>
<MenuPopup align="end">
{scripts.map((script) => {
const shortcutLabel = shortcutLabelForCommand(
keybindings,
commandForProjectScript(script.id),
);
return (
<MenuItem
key={script.id}
className={`group ${dropdownItemClassName}`}
onClick={() => onRunScript(script)}
>
<ScriptIcon icon={script.icon} className="size-4" />
<span className="truncate">
{script.runOnWorktreeCreate ? `${script.name} (setup)` : script.name}
</span>
<span className="relative ms-auto flex h-6 min-w-6 items-center justify-end">
{shortcutLabel && (
<MenuShortcut className="ms-0 transition-opacity group-hover:opacity-0 group-focus-visible:opacity-0">
{shortcutLabel}
</MenuShortcut>
)}
<Button
type="button"
variant="ghost"
size="icon-xs"
className="absolute right-0 top-1/2 size-6 -translate-y-1/2 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"
aria-label={`Edit ${script.name}`}
onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
openEditDialog(script);
}}
>
<SettingsIcon className="size-3.5" />
</Button>
</span>
</MenuItem>
);
})}
<MenuItem className={dropdownItemClassName} onClick={openAddDialog}>
<PlusIcon className="size-4" />
Add action
</MenuItem>
</MenuPopup>
</Menu>
</Group>
) : (
<Button size="xs" variant="outline" onClick={openAddDialog} title="Add action">
<PlusIcon className="size-3.5" />
<span className="sr-only @sm/header-actions:not-sr-only @sm/header-actions:ml-0.5">
Add action
</span>
</Button>
)}
<Dialog
onOpenChange={(open) => {
setDialogOpen(open);
if (!open) {
setIconPickerOpen(false);
}
}}
onOpenChangeComplete={(open) => {
if (open) return;
setEditingScriptId(null);
setName("");
setCommand("");
setIcon("play");
setRunOnWorktreeCreate(false);
setKeybinding("");
setValidationError(null);
}}
open={dialogOpen}
>
<DialogPopup>
<DialogHeader>
<DialogTitle>{isEditing ? "Edit Action" : "Add Action"}</DialogTitle>
<DialogDescription>
Actions are project-scoped commands you can run from the top bar or keybindings.
</DialogDescription>
</DialogHeader>
<DialogPanel>
<form id={addScriptFormId} className="space-y-4" onSubmit={submitAddScript}>
<div className="space-y-1.5">
<Label htmlFor="script-name">Name</Label>
<div className="flex items-center gap-2">
<Popover onOpenChange={setIconPickerOpen} open={iconPickerOpen}>
<PopoverTrigger
render={
<Button
type="button"
variant="outline"
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:data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)]"
aria-label="Choose icon"
/>
}
>
<ScriptIcon icon={icon} className="size-4.5" />
</PopoverTrigger>
<PopoverPopup align="start">
<div className="grid grid-cols-3 gap-2">
{SCRIPT_ICONS.map((entry) => {
const isSelected = entry.id === icon;
return (
<button
key={entry.id}
type="button"
className={`relative flex flex-col items-center gap-2 rounded-md border px-2 py-2 text-xs ${
isSelected
? "border-primary/70 bg-primary/10"
: "border-border/70 hover:bg-accent/60"
}`}
onClick={() => {
setIcon(entry.id);
setIconPickerOpen(false);
}}
>
<ScriptIcon icon={entry.id} className="size-4" />
<span>{entry.label}</span>
</button>
);
})}
</div>
</PopoverPopup>
</Popover>
<Input
id="script-name"
placeholder="Test"
value={name}
onChange={(event) => setName(event.target.value)}
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="script-keybinding">Keybinding</Label>
<Input
id="script-keybinding"
placeholder="Press shortcut"
value={keybinding}
readOnly
onKeyDown={captureKeybinding}
/>
<p className="text-xs text-muted-foreground">
Press a shortcut. Use <code>Backspace</code> to clear.
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="script-command">Command</Label>
<Textarea
id="script-command"
placeholder="bun test"
value={command}
onChange={(event) => setCommand(event.target.value)}
/>
</div>
<label className="flex items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2 text-sm">
<span>Run automatically on worktree creation</span>
<Switch
checked={runOnWorktreeCreate}
onCheckedChange={(checked) => setRunOnWorktreeCreate(Boolean(checked))}
/>
</label>
{validationError && <p className="text-sm text-destructive">{validationError}</p>}
</form>
</DialogPanel>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
setDialogOpen(false);
}}
>
Cancel
</Button>
<Button form={addScriptFormId} type="submit">
{isEditing ? "Save changes" : "Save action"}
</Button>
</DialogFooter>
</DialogPopup>
</Dialog>
</>
);
}