import type { ProjectScript, ProjectScriptIcon, ResolvedKeybindingsConfig, } from "@t3tools/contracts"; import { isAtomCommandInterrupted, squashAtomCommandFailure, type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; import { BugIcon, ChevronDownIcon, FlaskConicalIcon, HammerIcon, ListChecksIcon, PlayIcon, PlusIcon, SettingsIcon, WrenchIcon, } from "lucide-react"; import React, { type FormEvent, type KeyboardEvent, useCallback, useMemo, useState } from "react"; import { keybindingValueForCommand, decodeProjectScriptKeybindingRule, } from "~/lib/projectScriptKeybindings"; import { keybindingFromKeyboardEvent } from "~/components/settings/KeybindingsSettings.logic"; import { commandForProjectScript, nextProjectScriptId, primaryProjectScript, } from "~/projectScripts"; import { shortcutLabelForCommand } from "~/keybindings"; import { AlertDialog, AlertDialogClose, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogPopup, AlertDialogTitle, } from "./ui/alert-dialog"; 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"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; 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; /** Optional URL to open in the in-app preview when this script runs. */ previewUrl: string | null; /** When true, automatically open the preview panel pointed at `previewUrl`. */ autoOpenPreview: boolean; } export type ProjectScriptActionResult = AtomCommandResult; interface ProjectScriptsControlProps { scripts: ReadonlyArray; keybindings: ResolvedKeybindingsConfig; preferredScriptId?: string | null; onRunScript: (script: ProjectScript) => void; onAddScript: (input: NewProjectScriptInput) => Promise; onUpdateScript: ( scriptId: string, input: NewProjectScriptInput, ) => Promise; onDeleteScript: (scriptId: string) => Promise; } export default function ProjectScriptsControl({ scripts, keybindings, preferredScriptId = null, onRunScript, onAddScript, onUpdateScript, onDeleteScript, }: 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 [previewUrl, setPreviewUrl] = useState(""); const [autoOpenPreview, setAutoOpenPreview] = useState(false); const [validationError, setValidationError] = useState(null); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); 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 = keybindingFromKeyboardEvent(event, navigator.platform); 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); let payload: NewProjectScriptInput; try { const scriptIdForValidation = editingScriptId ?? nextProjectScriptId( trimmedName, scripts.map((script) => script.id), ); const keybindingRule = decodeProjectScriptKeybindingRule({ keybinding, command: commandForProjectScript(scriptIdForValidation), }); const trimmedPreviewUrl = previewUrl.trim(); payload = { name: trimmedName, command: trimmedCommand, icon, runOnWorktreeCreate, keybinding: keybindingRule?.key ?? null, previewUrl: trimmedPreviewUrl.length > 0 ? trimmedPreviewUrl : null, autoOpenPreview: trimmedPreviewUrl.length > 0 ? autoOpenPreview : false, } satisfies NewProjectScriptInput; } catch (error) { setValidationError(error instanceof Error ? error.message : "Failed to save action."); return; } const result = editingScriptId ? await onUpdateScript(editingScriptId, payload) : await onAddScript(payload); if (result._tag === "Failure") { if (!isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); setValidationError(error instanceof Error ? error.message : "Failed to save action."); } return; } setDialogOpen(false); setIconPickerOpen(false); }; const openAddDialog = () => { setEditingScriptId(null); setName(""); setCommand(""); setIcon("play"); setIconPickerOpen(false); setRunOnWorktreeCreate(false); setKeybinding(""); setPreviewUrl(""); setAutoOpenPreview(false); 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)) ?? ""); setPreviewUrl(script.previewUrl ?? ""); setAutoOpenPreview(script.autoOpenPreview ?? false); setValidationError(null); setDialogOpen(true); }; const confirmDeleteScript = useCallback(() => { if (!editingScriptId) return; setDeleteConfirmOpen(false); setDialogOpen(false); void onDeleteScript(editingScriptId); }, [editingScriptId, onDeleteScript]); return ( <> {primaryScript ? ( onRunScript(primaryScript)} /> } > {primaryScript.name} Run {primaryScript.name} } > {scripts.map((script) => { const shortcutLabel = shortcutLabelForCommand( keybindings, commandForProjectScript(script.id), ); return ( onRunScript(script)} > {script.runOnWorktreeCreate ? `${script.name} (setup)` : script.name} {shortcutLabel && ( {shortcutLabel} )} ); })} Add action ) : ( } > Add action Add action )} { setDialogOpen(open); if (!open) { setIconPickerOpen(false); } }} onOpenChangeComplete={(open) => { if (open) return; setEditingScriptId(null); setName(""); setCommand(""); setIcon("play"); setRunOnWorktreeCreate(false); setKeybinding(""); setPreviewUrl(""); setAutoOpenPreview(false); setValidationError(null); }} open={dialogOpen} > {isEditing ? "Edit Action" : "Add Action"} Actions are project-scoped commands you can run from the top bar or keybindings.
} >
{SCRIPT_ICONS.map((entry) => { const isSelected = entry.id === icon; return ( ); })}
setName(event.target.value)} />

Press a shortcut. Use Backspace to clear.