diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 605127f9737..b3e845f0007 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1505,6 +1505,17 @@ function OpenCommandPaletteDialog(props: { }, }); + actionItems.push({ + kind: "action", + value: "action:project-settings", + searchTerms: ["project", "settings", "scripts", "model", "grouping", "checkout"], + title: "Project settings", + icon: , + run: async () => { + await navigate({ to: "/settings/projects" }); + }, + }); + const rootGroups = buildRootGroups({ actionItems, recentThreadItems }); const sourceSelectionViewValue = addProjectEnvironmentId === null ? null : `sources:${addProjectEnvironmentId}`; diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx index 7f21177e7b1..304922909b0 100644 --- a/apps/web/src/components/ProjectScriptsControl.tsx +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -1,61 +1,28 @@ 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 { ChevronDownIcon, DownloadIcon, PlusIcon, SettingsIcon } from "lucide-react"; +import { 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 { commandForProjectScript, primaryProjectScript } from "~/projectScripts"; import { shortcutLabelForCommand } from "~/keybindings"; import { - AlertDialog, - AlertDialogClose, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogPopup, - AlertDialogTitle, -} from "./ui/alert-dialog"; + EMPTY_PROJECT_SCRIPT_INPUT, + editorRequestForScript, + ProjectScriptEditorDialog, + ScriptIcon, + type NewProjectScriptInput, + type ProjectScriptActionResult, + type ProjectScriptEditorRequest, +} from "./projectScriptEditor"; 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, @@ -66,48 +33,9 @@ import { 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 ; - 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 type { NewProjectScriptInput, ProjectScriptActionResult }; const NO_FILE_SCRIPTS: ReadonlyArray = []; @@ -136,23 +64,11 @@ export default function ProjectScriptsControl({ onUpdateScript, onDeleteScript, }: ProjectScriptsControlProps) { - const addScriptFormId = React.useId(); - const [editingScriptId, setEditingScriptId] = useState(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("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 [editorRequest, setEditorRequest] = useState(null); const primaryScript = useMemo(() => { if (preferredScriptId) { @@ -173,112 +89,23 @@ export default function ProjectScriptsControl({ ), [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) => { - 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); + setEditorRequest({ scriptId: null, initial: EMPTY_PROJECT_SCRIPT_INPUT }); }; 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); + setEditorRequest(editorRequestForScript(script, keybindings)); }; - const confirmDeleteScript = useCallback(() => { - if (!editingScriptId) return; - setDeleteConfirmOpen(false); - setDialogOpen(false); - void onDeleteScript(editingScriptId); - }, [editingScriptId, onDeleteScript]); + const submitScript = useCallback( + (scriptId: string | null, input: NewProjectScriptInput) => + scriptId === null ? onAddScript(input) : onUpdateScript(scriptId, input), + [onAddScript, onUpdateScript], + ); const importFileScript = async (fileScript: T3ProjectFileScript) => { const payload: NewProjectScriptInput = { @@ -295,17 +122,11 @@ export default function ProjectScriptsControl({ // 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); + setEditorRequest({ + scriptId: null, + initial: payload, + error: error instanceof Error ? error.message : "Failed to import action.", + }); } }; @@ -466,184 +287,13 @@ export default function ProjectScriptsControl({ )} - { - 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} - > - - - {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. -

-
-
- -