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 <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>; 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<ProjectScript>; onSubmit: ( scriptId: string | null, input: NewProjectScriptInput, ) => Promise<ProjectScriptActionResult>; onDelete: (scriptId: string) => void; onClose: () => void;}) { const formId = React.useId(); 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 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<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 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 ( <> <Dialog open={isOpen} onOpenChange={(open) => { if (!open) { setIconPickerOpen(false); onClose(); } }} > <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={formId} className="space-y-4" onSubmit={submit}> <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:border-transparent dark:bg-white/[0.035] dark:data-pressed:before:shadow-none" 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 dark:border-transparent ${ isSelected ? "border-primary/70 bg-primary/10 dark:ring-1 dark:ring-primary/30" : "border-border/70 hover:bg-accent/60 dark:bg-white/[0.035]" }`} 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> <Textarea id="script-command" placeholder="bun test" value={command} onChange={(event) => setCommand(event.target.value)} /> </div> <div className="space-y-1.5"> <Label htmlFor="script-preview-url">Preview URL (optional)</Label> <Input id="script-preview-url" placeholder="http://localhost:5173" value={previewUrl} onChange={(event) => setPreviewUrl(event.target.value)} /> <p className="text-xs text-muted-foreground"> Open this URL in the in-app preview when this action runs. </p> </div> <label className="flex items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2 text-sm dark:border-transparent dark:bg-white/[0.035]"> <span>Run automatically on worktree creation</span> <Switch checked={runOnWorktreeCreate} onCheckedChange={(checked) => setRunOnWorktreeCreate(Boolean(checked))} /> </label> <label className={`flex items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2 text-sm dark:border-transparent dark:bg-white/[0.035] ${ previewUrl.trim().length === 0 ? "opacity-60" : "" }`} > <span>Open preview automatically when this action runs</span> <Switch checked={autoOpenPreview} disabled={previewUrl.trim().length === 0} onCheckedChange={(checked) => setAutoOpenPreview(Boolean(checked))} /> </label> {validationError && <p className="text-sm text-destructive">{validationError}</p>} </form> </DialogPanel> <DialogFooter className="dark:border-transparent dark:bg-transparent"> {isEditing && ( <Button type="button" variant="destructive-outline" className="mr-auto" onClick={() => setDeleteConfirmOpen(true)} > Delete </Button> )} <Button type="button" variant="outline" onClick={onClose}> Cancel </Button> <Button form={formId} type="submit"> {isEditing ? "Save changes" : "Save action"} </Button> </DialogFooter> </DialogPopup> </Dialog> <AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}> <AlertDialogPopup> <AlertDialogHeader> <AlertDialogTitle>Delete action "{name}"?</AlertDialogTitle> <AlertDialogDescription>This action cannot be undone.</AlertDialogDescription> </AlertDialogHeader> <AlertDialogFooter> <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose> <Button variant="destructive" onClick={() => { if (!request?.scriptId) return; setDeleteConfirmOpen(false); onClose(); onDelete(request.scriptId); }} > Delete action </Button> </AlertDialogFooter> </AlertDialogPopup> </AlertDialog> </> );}
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 <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>;
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<ProjectScript>;
onSubmit: (
scriptId: string | null,
input: NewProjectScriptInput,
) => Promise<ProjectScriptActionResult>;
onDelete: (scriptId: string) => void;
onClose: () => void;
}) {
const formId = React.useId();
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 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<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 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 (
<>
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open) {
setIconPickerOpen(false);
onClose();
}
}}
>
<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={formId} className="space-y-4" onSubmit={submit}>
<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:border-transparent dark:bg-white/[0.035] dark:data-pressed:before:shadow-none"
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 dark:border-transparent ${
isSelected
? "border-primary/70 bg-primary/10 dark:ring-1 dark:ring-primary/30"
: "border-border/70 hover:bg-accent/60 dark:bg-white/[0.035]"
}`}
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>
<Textarea
id="script-command"
placeholder="bun test"
value={command}
onChange={(event) => setCommand(event.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="script-preview-url">Preview URL (optional)</Label>
<Input
id="script-preview-url"
placeholder="http://localhost:5173"
value={previewUrl}
onChange={(event) => setPreviewUrl(event.target.value)}
/>
<p className="text-xs text-muted-foreground">
Open this URL in the in-app preview when this action runs.
</p>
</div>
<label className="flex items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2 text-sm dark:border-transparent dark:bg-white/[0.035]">
<span>Run automatically on worktree creation</span>
<Switch
checked={runOnWorktreeCreate}
onCheckedChange={(checked) => setRunOnWorktreeCreate(Boolean(checked))}
/>
</label>
<label
className={`flex items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2 text-sm dark:border-transparent dark:bg-white/[0.035] ${
previewUrl.trim().length === 0 ? "opacity-60" : ""
}`}
>
<span>Open preview automatically when this action runs</span>
<Switch
checked={autoOpenPreview}
disabled={previewUrl.trim().length === 0}
onCheckedChange={(checked) => setAutoOpenPreview(Boolean(checked))}
/>
</label>
{validationError && <p className="text-sm text-destructive">{validationError}</p>}
</form>
</DialogPanel>
<DialogFooter className="dark:border-transparent dark:bg-transparent">
{isEditing && (
<Button
type="button"
variant="destructive-outline"
className="mr-auto"
onClick={() => setDeleteConfirmOpen(true)}
>
Delete
</Button>
)}
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button form={formId} type="submit">
{isEditing ? "Save changes" : "Save action"}
</Button>
</DialogFooter>
</DialogPopup>
</Dialog>
<AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
<AlertDialogPopup>
<AlertDialogHeader>
<AlertDialogTitle>Delete action "{name}"?</AlertDialogTitle>
<AlertDialogDescription>This action cannot be undone.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
<Button
variant="destructive"
onClick={() => {
if (!request?.scriptId) return;
setDeleteConfirmOpen(false);
onClose();
onDelete(request.scriptId);
}}
>
Delete action
</Button>
</AlertDialogFooter>
</AlertDialogPopup>
</AlertDialog>
</>
);
}