505 lines · 18.0 KB · TypeScript
RawDownload
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, useCallback, 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 {  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"; 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;} interface ProjectScriptsControlProps {  scripts: ProjectScript[];  keybindings: ResolvedKeybindingsConfig;  preferredScriptId?: string | null;  onRunScript: (script: ProjectScript) => void;  onAddScript: (input: NewProjectScriptInput) => Promise<void> | void;  onUpdateScript: (scriptId: string, input: NewProjectScriptInput) => Promise<void> | void;  onDeleteScript: (scriptId: string) => Promise<void> | 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<HTMLInputElement>): 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,  onDeleteScript,}: ProjectScriptsControlProps) {  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 [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 = 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);  };   const confirmDeleteScript = useCallback(() => {    if (!editingScriptId) return;    setDeleteConfirmOpen(false);    setDialogOpen(false);    void onDeleteScript(editingScriptId);  }, [editingScriptId, onDeleteScript]);   return (    <>      {primaryScript ? (        <Group aria-label="Project scripts">          <Button            size="xs"            variant="outline"            onClick={() => onRunScript(primaryScript)}            title={`Run ${primaryScript.name}`}          >            <ScriptIcon icon={primaryScript.icon} />            <span className="sr-only @sm/header-actions:not-sr-only @sm/header-actions:ml-0.5">              {primaryScript.name}            </span>          </Button>          <GroupSeparator className="hidden @sm/header-actions:block" />          <Menu highlightItemOnHover={false}>            <MenuTrigger              render={<Button size="icon-xs" variant="outline" aria-label="Script actions" />}            >              <ChevronDownIcon className="size-4" />            </MenuTrigger>            <MenuPopup align="end">              {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" />                Add action              </MenuItem>            </MenuPopup>          </Menu>        </Group>      ) : (        <Button size="xs" variant="outline" onClick={openAddDialog} title="Add action">          <PlusIcon className="size-3.5" />          <span className="sr-only @sm/header-actions:not-sr-only @sm/header-actions:ml-0.5">            Add action          </span>        </Button>      )}       <Dialog        onOpenChange={(open) => {          setDialogOpen(open);          if (!open) {            setIconPickerOpen(false);          }        }}        onOpenChangeComplete={(open) => {          if (open) return;          setEditingScriptId(null);          setName("");          setCommand("");          setIcon("play");          setRunOnWorktreeCreate(false);          setKeybinding("");          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>                <Textarea                  id="script-command"                  placeholder="bun test"                  value={command}                  onChange={(event) => setCommand(event.target.value)}                />              </div>              <label className="flex items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2 text-sm">                <span>Run automatically on worktree creation</span>                <Switch                  checked={runOnWorktreeCreate}                  onCheckedChange={(checked) => setRunOnWorktreeCreate(Boolean(checked))}                />              </label>              {validationError && <p className="text-sm text-destructive">{validationError}</p>}            </form>          </DialogPanel>          <DialogFooter>            {isEditing && (              <Button                type="button"                variant="destructive-outline"                className="mr-auto"                onClick={() => setDeleteConfirmOpen(true)}              >                Delete              </Button>            )}            <Button              type="button"              variant="outline"              onClick={() => {                setDialogOpen(false);              }}            >              Cancel            </Button>            <Button form={addScriptFormId} 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={confirmDeleteScript}>              Delete action            </Button>          </AlertDialogFooter>

Lines 1–500 of 505.