649 lines · 24.1 KB · TypeScript
RawDownload
import type {  ProjectScript,  ProjectScriptIcon,  ResolvedKeybindingsConfig,  T3ProjectFileScript,} from "@t3tools/contracts";import {  isAtomCommandInterrupted,  squashAtomCommandFailure,  type AtomCommandResult,} from "@t3tools/client-runtime/state/runtime";import {  BugIcon,  ChevronDownIcon,  DownloadIcon,  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,  MenuGroup,  MenuGroupLabel,  MenuItem,  MenuPopup,  MenuSeparator,  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 <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>; const NO_FILE_SCRIPTS: ReadonlyArray<T3ProjectFileScript> = []; interface ProjectScriptsControlProps {  scripts: ReadonlyArray<ProjectScript>;  /** Scripts declared in the project's checked-in t3.json, offered for import. */  fileScripts?: ReadonlyArray<T3ProjectFileScript>;  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({  scripts,  fileScripts = NO_FILE_SCRIPTS,  keybindings,  preferredScriptId = null,  onRunScript,  onAddScript,  onUpdateScript,  onDeleteScript,}: ProjectScriptsControlProps) {  const addScriptFormId = React.useId();  const [editingScriptId, setEditingScriptId] = useState<string | null>(null);  const [actionsMenuOpen, setActionsMenuOpen] = useState({    scripts: false,    imports: false,  });  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 importableScripts = useMemo(    () =>      fileScripts.filter(        (fileScript) =>          !scripts.some(            (script) =>              script.command === fileScript.command ||              script.name.toLowerCase() === fileScript.name.toLowerCase(),          ),      ),    [fileScripts, 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) => {    setActionsMenuOpen({ scripts: false, imports: false });    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]);   const importFileScript = async (fileScript: T3ProjectFileScript) => {    const payload: NewProjectScriptInput = {      name: fileScript.name,      command: fileScript.command,      icon: fileScript.icon ?? "play",      runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false,      keybinding: null,      previewUrl: fileScript.previewUrl ?? null,      autoOpenPreview: fileScript.previewUrl ? (fileScript.autoOpenPreview ?? false) : false,    };    const result = await onAddScript(payload);    if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {      // Surface the failure through the regular add dialog, prefilled so the      // user can adjust and retry.      const error = squashAtomCommandFailure(result);      setEditingScriptId(null);      setName(payload.name);      setCommand(payload.command);      setIcon(payload.icon);      setIconPickerOpen(false);      setRunOnWorktreeCreate(payload.runOnWorktreeCreate);      setKeybinding("");      setPreviewUrl(payload.previewUrl ?? "");      setAutoOpenPreview(payload.autoOpenPreview);      setValidationError(error instanceof Error ? error.message : "Failed to import action.");      setDialogOpen(true);    }  };   const importMenuItems = importableScripts.length > 0 && (    <>      {primaryScript && <MenuSeparator />}      <MenuGroup>        <MenuGroupLabel>From t3.json</MenuGroupLabel>        {importableScripts.map((fileScript) => (          <MenuItem            key={`${fileScript.name} ${fileScript.command}`}            className={dropdownItemClassName}            onClick={() => void importFileScript(fileScript)}          >            <ScriptIcon icon={fileScript.icon ?? "play"} className="size-4" />            <span className="truncate">{fileScript.name}</span>            <MenuShortcut className="ms-auto">              <DownloadIcon className="size-3.5" aria-label="Import" />            </MenuShortcut>          </MenuItem>        ))}      </MenuGroup>    </>  );   return (    <>      {primaryScript ? (        <Group aria-label="Project scripts">          <Tooltip>            <TooltipTrigger              render={                <Button                  size="xs"                  variant="outline"                  className="w-7 px-0 sm:w-6 @3xl/header-actions:w-auto! @3xl/header-actions:px-[calc(--spacing(2)-1px)]"                  aria-label={`Run ${primaryScript.name}`}                  // The tooltip wrapper replaces data-slot="button", so themed                  // toolbar styling needs its own hook.                  data-toolbar-control=""                  onClick={() => onRunScript(primaryScript)}                />              }            >              <ScriptIcon icon={primaryScript.icon} />              <span className="sr-only @3xl/header-actions:not-sr-only @3xl/header-actions:ml-0.5">                {primaryScript.name}              </span>            </TooltipTrigger>            <TooltipPopup side="top">Run {primaryScript.name}</TooltipPopup>          </Tooltip>          <GroupSeparator className="hidden @3xl/header-actions:block" />          <Menu            highlightItemOnHover={false}            open={actionsMenuOpen.scripts}            onOpenChange={(open) => setActionsMenuOpen({ scripts: open, imports: 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>                );              })}              {importMenuItems}              <MenuItem className={dropdownItemClassName} onClick={openAddDialog}>                <PlusIcon className="size-4" />                Add action              </MenuItem>            </MenuPopup>          </Menu>        </Group>      ) : importableScripts.length > 0 ? (        <Menu          highlightItemOnHover={false}          open={actionsMenuOpen.imports}          onOpenChange={(open) => setActionsMenuOpen({ scripts: false, imports: open })}        >          <MenuTrigger render={<Button size="xs" variant="outline" aria-label="Project actions" />}>            <PlusIcon className="size-3.5" />            <span className="sr-only @3xl/header-actions:not-sr-only @3xl/header-actions:ml-0.5">              Add action            </span>            <ChevronDownIcon className="size-3.5" />          </MenuTrigger>          <MenuPopup align="end">            {importMenuItems}            <MenuItem className={dropdownItemClassName} onClick={openAddDialog}>              <PlusIcon className="size-4" />              Add action            </MenuItem>          </MenuPopup>        </Menu>      ) : (        <Tooltip>          <TooltipTrigger            render={              <Button                size="xs"                variant="outline"                className="w-7 px-0 sm:w-6 @3xl/header-actions:w-auto! @3xl/header-actions:px-[calc(--spacing(2)-1px)]"                aria-label="Add action"                // The tooltip wrapper replaces data-slot="button", so themed                // toolbar styling needs its own hook.                data-toolbar-control=""                onClick={openAddDialog}              />            }          >            <PlusIcon className="size-3.5" />            <span className="sr-only @3xl/header-actions:not-sr-only @3xl/header-actions:ml-0.5">              Add action            </span>          </TooltipTrigger>          <TooltipPopup side="top">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>

Lines 1–500 of 649.