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";import { cn } from "~/lib/utils";import { THREAD_DETAILS_PANEL_CHEVRON_CLASS, THREAD_DETAILS_PANEL_ICON_CLASS, THREAD_DETAILS_PANEL_ROW_POPUP_CLASS, THREAD_DETAILS_PANEL_ROW_CLASS, THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS, THREAD_DETAILS_PANEL_SPLIT_PRIMARY_CLASS, THREAD_DETAILS_PANEL_SPLIT_SECONDARY_CLASS, THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS,} from "./chat/threadDetailsPanelStyles"; 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; /** 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<void, unknown>; interface ProjectScriptsControlProps { displayMode?: "toolbar" | "panel"; scripts: ReadonlyArray<ProjectScript>; keybindings: ResolvedKeybindingsConfig; preferredScriptId?: string | null; onRunScript: (script: ProjectScript) => void; onAddScript: (input: NewProjectScriptInput) => Promise<ProjectScriptActionResult>; onUpdateScript: ( scriptId: string, input: NewProjectScriptInput, ) => Promise<ProjectScriptActionResult>; onDeleteScript: (scriptId: string) => Promise<ProjectScriptActionResult>;} export default function ProjectScriptsControl({ displayMode = "toolbar", scripts, keybindings, preferredScriptId = null, onRunScript, onAddScript, onUpdateScript, onDeleteScript,}: ProjectScriptsControlProps) { const isPanel = displayMode === "panel"; const ActionGroup = isPanel ? "div" : Group; const panelAnchorRef = React.useRef<HTMLDivElement | null>(null); 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 [previewUrl, setPreviewUrl] = useState(""); const [autoOpenPreview, setAutoOpenPreview] = useState(false); const [validationError, setValidationError] = useState<string | null>(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<HTMLInputElement>) => { 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 ? ( <ActionGroup role="group" aria-label="Project scripts" {...(isPanel ? { className: THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS, ref: panelAnchorRef } : {})} > <Tooltip> <TooltipTrigger render={ <Button size="xs" variant={isPanel ? "ghost" : "outline"} className={isPanel ? THREAD_DETAILS_PANEL_SPLIT_PRIMARY_CLASS : undefined} aria-label={`Run ${primaryScript.name}`} onClick={() => onRunScript(primaryScript)} /> } > <ScriptIcon icon={primaryScript.icon} {...(isPanel ? { className: THREAD_DETAILS_PANEL_ICON_CLASS } : {})} /> <span className={cn( "sr-only @3xl/header-actions:not-sr-only @3xl/header-actions:ml-0.5", isPanel && "not-sr-only ml-0.5 truncate", )} > {primaryScript.name} </span> </TooltipTrigger> <TooltipPopup side="top">Run {primaryScript.name}</TooltipPopup> </Tooltip> {isPanel ? ( <span aria-hidden="true" className={THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS} /> ) : ( <GroupSeparator className="hidden @3xl/header-actions:block" /> )} <Menu highlightItemOnHover={false}> <MenuTrigger render={ <Button size={isPanel ? "sm" : "icon-xs"} variant={isPanel ? "ghost" : "outline"} className={isPanel ? THREAD_DETAILS_PANEL_SPLIT_SECONDARY_CLASS : undefined} aria-label="Script actions" /> } > <ChevronDownIcon className={isPanel ? THREAD_DETAILS_PANEL_CHEVRON_CLASS : "size-4"} /> </MenuTrigger> <MenuPopup align="end" {...(isPanel ? { anchor: panelAnchorRef } : {})} className={isPanel ? THREAD_DETAILS_PANEL_ROW_POPUP_CLASS : undefined} > {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" /> {isPanel ? "Add project script" : "Add action"} </MenuItem> </MenuPopup> </Menu> </ActionGroup> ) : ( <Tooltip> <TooltipTrigger render={ <Button size="xs" variant={isPanel ? "ghost" : "outline"} className={isPanel ? THREAD_DETAILS_PANEL_ROW_CLASS : undefined} aria-label={isPanel ? "Add project script" : "Add action"} onClick={openAddDialog} /> } > <PlusIcon className="size-3.5" /> <span className={cn( "sr-only @3xl/header-actions:not-sr-only @3xl/header-actions:ml-0.5", isPanel && "not-sr-only ml-0.5", )} > {isPanel ? "Add project script" : "Add action"} </span> </TooltipTrigger> <TooltipPopup side="top">{isPanel ? "Add project script" : "Add action"}</TooltipPopup> </Tooltip> )} <Dialog onOpenChange={(open) => { 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} > <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" autoFocus 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>Lines 1–500 of 585. Load more
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"; import { cn } from "~/lib/utils"; import { THREAD_DETAILS_PANEL_CHEVRON_CLASS, THREAD_DETAILS_PANEL_ICON_CLASS, THREAD_DETAILS_PANEL_ROW_POPUP_CLASS, THREAD_DETAILS_PANEL_ROW_CLASS, THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS, THREAD_DETAILS_PANEL_SPLIT_PRIMARY_CLASS, THREAD_DETAILS_PANEL_SPLIT_SECONDARY_CLASS, THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS, } from "./chat/threadDetailsPanelStyles"; 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; /** 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<void, unknown>; interface ProjectScriptsControlProps { displayMode?: "toolbar" | "panel"; scripts: ReadonlyArray<ProjectScript>; keybindings: ResolvedKeybindingsConfig; preferredScriptId?: string | null; onRunScript: (script: ProjectScript) => void; onAddScript: (input: NewProjectScriptInput) => Promise<ProjectScriptActionResult>; onUpdateScript: ( scriptId: string, input: NewProjectScriptInput, ) => Promise<ProjectScriptActionResult>; onDeleteScript: (scriptId: string) => Promise<ProjectScriptActionResult>; } export default function ProjectScriptsControl({ displayMode = "toolbar", scripts, keybindings, preferredScriptId = null, onRunScript, onAddScript, onUpdateScript, onDeleteScript, }: ProjectScriptsControlProps) { const isPanel = displayMode === "panel"; const ActionGroup = isPanel ? "div" : Group; const panelAnchorRef = React.useRef<HTMLDivElement | null>(null); 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 [previewUrl, setPreviewUrl] = useState(""); const [autoOpenPreview, setAutoOpenPreview] = useState(false); const [validationError, setValidationError] = useState<string | null>(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<HTMLInputElement>) => { 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 ? ( <ActionGroup role="group" aria-label="Project scripts" {...(isPanel ? { className: THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS, ref: panelAnchorRef } : {})} > <Tooltip> <TooltipTrigger render={ <Button size="xs" variant={isPanel ? "ghost" : "outline"} className={isPanel ? THREAD_DETAILS_PANEL_SPLIT_PRIMARY_CLASS : undefined} aria-label={`Run ${primaryScript.name}`} onClick={() => onRunScript(primaryScript)} /> } > <ScriptIcon icon={primaryScript.icon} {...(isPanel ? { className: THREAD_DETAILS_PANEL_ICON_CLASS } : {})} /> <span className={cn( "sr-only @3xl/header-actions:not-sr-only @3xl/header-actions:ml-0.5", isPanel && "not-sr-only ml-0.5 truncate", )} > {primaryScript.name} </span> </TooltipTrigger> <TooltipPopup side="top">Run {primaryScript.name}</TooltipPopup> </Tooltip> {isPanel ? ( <span aria-hidden="true" className={THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS} /> ) : ( <GroupSeparator className="hidden @3xl/header-actions:block" /> )} <Menu highlightItemOnHover={false}> <MenuTrigger render={ <Button size={isPanel ? "sm" : "icon-xs"} variant={isPanel ? "ghost" : "outline"} className={isPanel ? THREAD_DETAILS_PANEL_SPLIT_SECONDARY_CLASS : undefined} aria-label="Script actions" /> } > <ChevronDownIcon className={isPanel ? THREAD_DETAILS_PANEL_CHEVRON_CLASS : "size-4"} /> </MenuTrigger> <MenuPopup align="end" {...(isPanel ? { anchor: panelAnchorRef } : {})} className={isPanel ? THREAD_DETAILS_PANEL_ROW_POPUP_CLASS : undefined} > {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" /> {isPanel ? "Add project script" : "Add action"} </MenuItem> </MenuPopup> </Menu> </ActionGroup> ) : ( <Tooltip> <TooltipTrigger render={ <Button size="xs" variant={isPanel ? "ghost" : "outline"} className={isPanel ? THREAD_DETAILS_PANEL_ROW_CLASS : undefined} aria-label={isPanel ? "Add project script" : "Add action"} onClick={openAddDialog} /> } > <PlusIcon className="size-3.5" /> <span className={cn( "sr-only @3xl/header-actions:not-sr-only @3xl/header-actions:ml-0.5", isPanel && "not-sr-only ml-0.5", )} > {isPanel ? "Add project script" : "Add action"} </span> </TooltipTrigger> <TooltipPopup side="top">{isPanel ? "Add project script" : "Add action"}</TooltipPopup> </Tooltip> )} <Dialog onOpenChange={(open) => { 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} > <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" autoFocus 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>