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 ; if (icon === "lint") return ; if (icon === "configure") return ; if (icon === "build") return ; if (icon === "debug") return ; return ; } 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; onUpdateScript: (scriptId: string, input: NewProjectScriptInput) => Promise | 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): 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(null); const [dialogOpen, setDialogOpen] = useState(false); const [name, setName] = useState(""); const [command, setCommand] = useState(""); const [icon, setIcon] = useState("play"); const [iconPickerOpen, setIconPickerOpen] = useState(false); const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false); const [keybinding, setKeybinding] = useState(""); const [validationError, setValidationError] = useState(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) => { 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 ? ( onRunScript(primaryScript)} title={`Run ${primaryScript.name}`} > {primaryScript.name} } > {scripts.map((script) => { const shortcutLabel = shortcutLabelForCommand( keybindings, commandForProjectScript(script.id), ); return ( onRunScript(script)} > {script.runOnWorktreeCreate ? `${script.name} (setup)` : script.name} {shortcutLabel && ( {shortcutLabel} )} { event.preventDefault(); event.stopPropagation(); }} onClick={(event) => { event.preventDefault(); event.stopPropagation(); openEditDialog(script); }} > ); })} Add action ) : ( Add action )} { setDialogOpen(open); if (!open) { setIconPickerOpen(false); } }} onOpenChangeComplete={(open) => { if (open) return; setEditingScriptId(null); setName(""); setCommand(""); setIcon("play"); setRunOnWorktreeCreate(false); setKeybinding(""); setValidationError(null); }} open={dialogOpen} > {isEditing ? "Edit Action" : "Add Action"} Actions are project-scoped commands you can run from the top bar or keybindings. Name } > {SCRIPT_ICONS.map((entry) => { const isSelected = entry.id === icon; return ( { setIcon(entry.id); setIconPickerOpen(false); }} > {entry.label} ); })} setName(event.target.value)} /> Keybinding Press a shortcut. Use Backspace to clear. Command setCommand(event.target.value)} /> Run automatically on worktree creation setRunOnWorktreeCreate(Boolean(checked))} /> {validationError && {validationError}} { setDialogOpen(false); }} > Cancel {isEditing ? "Save changes" : "Save action"} > ); }
Press a shortcut. Use Backspace to clear.
Backspace
{validationError}