import type { ProjectScript, ProjectScriptIcon, ResolvedKeybindingsConfig, } from "@t3tools/contracts"; import { isAtomCommandInterrupted, squashAtomCommandFailure, type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; import { BugIcon, FlaskConicalIcon, HammerIcon, ListChecksIcon, PlayIcon, WrenchIcon, } from "lucide-react"; import React, { type FormEvent, type KeyboardEvent, useEffect, useState } from "react"; import { keybindingValueForCommand, decodeProjectScriptKeybindingRule, } from "~/lib/projectScriptKeybindings"; import { keybindingFromKeyboardEvent } from "~/components/settings/KeybindingsSettings.logic"; import { commandForProjectScript, nextProjectScriptId } from "~/projectScripts"; 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 { Input } from "./ui/input"; import { Label } from "./ui/label"; 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" }, ]; export 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; export const EMPTY_PROJECT_SCRIPT_INPUT: NewProjectScriptInput = { name: "", command: "", icon: "play", runOnWorktreeCreate: false, keybinding: null, previewUrl: null, autoOpenPreview: false, }; /** What the editor dialog should open with. `scriptId: null` means "add". */ export interface ProjectScriptEditorRequest { scriptId: string | null; initial: NewProjectScriptInput; /** Validation error to show immediately (e.g. a failed t3.json import). */ error?: string; } export function editorRequestForScript( script: ProjectScript, keybindings: ResolvedKeybindingsConfig, ): ProjectScriptEditorRequest { return { scriptId: script.id, initial: { name: script.name, command: script.command, icon: script.icon, runOnWorktreeCreate: script.runOnWorktreeCreate, keybinding: keybindingValueForCommand(keybindings, commandForProjectScript(script.id)), previewUrl: script.previewUrl ?? null, autoOpenPreview: script.autoOpenPreview ?? false, }, }; } /** * Add/edit dialog for a project script, shared by the chat-header scripts menu * and the project settings page. The parent owns which script (if any) is * being edited via `request`; the dialog owns the form state and validation. */ export function ProjectScriptEditorDialog({ request, scripts, onSubmit, onDelete, onClose, }: { request: ProjectScriptEditorRequest | null; /** Existing scripts, used to derive a unique id for new scripts. */ scripts: ReadonlyArray; onSubmit: ( scriptId: string | null, input: NewProjectScriptInput, ) => Promise; onDelete: (scriptId: string) => void; onClose: () => void; }) { const formId = React.useId(); 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 isOpen = request !== null; const isEditing = request?.scriptId != null; // Hydrate the form whenever a new request opens the dialog. useEffect(() => { if (!request) return; setName(request.initial.name); setCommand(request.initial.command); setIcon(request.initial.icon); setIconPickerOpen(false); setRunOnWorktreeCreate(request.initial.runOnWorktreeCreate); setKeybinding(request.initial.keybinding ?? ""); setPreviewUrl(request.initial.previewUrl ?? ""); setAutoOpenPreview(request.initial.autoOpenPreview); setValidationError(request.error ?? null); }, [request]); 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 submit = async (event: FormEvent) => { event.preventDefault(); if (!request) return; 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 = request.scriptId ?? 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 = await onSubmit(request.scriptId, payload); if (result._tag === "Failure") { if (!isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); setValidationError(error instanceof Error ? error.message : "Failed to save action."); } return; } setIconPickerOpen(false); onClose(); }; return ( <> { if (!open) { setIconPickerOpen(false); onClose(); } }} > {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.