Unified · Split
| @@ -1505,6 +1505,17 @@ function OpenCommandPaletteDialog(props: { · 1504 unmodified lines | ||
| 1505 | 1505 | }, |
| 1506 | 1506 | }); |
| 1507 | 1507 | |
| 1508 | + actionItems.push({ | |
| 1509 | + kind: "action", | |
| 1510 | + value: "action:project-settings", | |
| 1511 | + searchTerms: ["project", "settings", "scripts", "model", "grouping", "checkout"], | |
| 1512 | + title: "Project settings", | |
| 1513 | + icon: <FolderIcon className={ITEM_ICON_CLASS} />, | |
| 1514 | + run: async () => { | |
| 1515 | + await navigate({ to: "/settings/projects" }); | |
| 1516 | + }, | |
| 1517 | + }); | |
| 1518 | + | |
| 1508 | 1519 | const rootGroups = buildRootGroups({ actionItems, recentThreadItems }); |
| 1509 | 1520 | const sourceSelectionViewValue = |
| 1510 | 1521 | addProjectEnvironmentId === null ? null : `sources:${addProjectEnvironmentId}`; |
| @@ -1,61 +1,28 @@ | ||
| 1 | 1 | import type { |
| 2 | 2 | ProjectScript, |
| 3 | − ProjectScriptIcon, | |
| 4 | 3 | ResolvedKeybindingsConfig, |
| 5 | 4 | T3ProjectFileScript, |
| 6 | 5 | } from "@t3tools/contracts"; |
| 7 | 6 | import { |
| 8 | 7 | isAtomCommandInterrupted, |
| 9 | 8 | squashAtomCommandFailure, |
| 10 | − type AtomCommandResult, | |
| 11 | 9 | } from "@t3tools/client-runtime/state/runtime"; |
| 12 | −import { | |
| 13 | − BugIcon, | |
| 14 | − ChevronDownIcon, | |
| 15 | − DownloadIcon, | |
| 16 | − FlaskConicalIcon, | |
| 17 | − HammerIcon, | |
| 18 | − ListChecksIcon, | |
| 19 | − PlayIcon, | |
| 20 | − PlusIcon, | |
| 21 | − SettingsIcon, | |
| 22 | − WrenchIcon, | |
| 23 | −} from "lucide-react"; | |
| 24 | −import React, { type FormEvent, type KeyboardEvent, useCallback, useMemo, useState } from "react"; | |
| 10 | +import { ChevronDownIcon, DownloadIcon, PlusIcon, SettingsIcon } from "lucide-react"; | |
| 11 | +import { useCallback, useMemo, useState } from "react"; | |
| 25 | 12 | |
| 26 | −import { | |
| 27 | − keybindingValueForCommand, | |
| 28 | − decodeProjectScriptKeybindingRule, | |
| 29 | −} from "~/lib/projectScriptKeybindings"; | |
| 30 | −import { keybindingFromKeyboardEvent } from "~/components/settings/KeybindingsSettings.logic"; | |
| 31 | −import { | |
| 32 | − commandForProjectScript, | |
| 33 | − nextProjectScriptId, | |
| 34 | − primaryProjectScript, | |
| 35 | −} from "~/projectScripts"; | |
| 13 | +import { commandForProjectScript, primaryProjectScript } from "~/projectScripts"; | |
| 36 | 14 | import { shortcutLabelForCommand } from "~/keybindings"; |
| 37 | 15 | import { |
| 38 | − AlertDialog, | |
| 39 | − AlertDialogClose, | |
| 40 | − AlertDialogDescription, | |
| 41 | − AlertDialogFooter, | |
| 42 | − AlertDialogHeader, | |
| 43 | − AlertDialogPopup, | |
| 44 | − AlertDialogTitle, | |
| 45 | −} from "./ui/alert-dialog"; | |
| 16 | + EMPTY_PROJECT_SCRIPT_INPUT, | |
| 17 | + editorRequestForScript, | |
| 18 | + ProjectScriptEditorDialog, | |
| 19 | + ScriptIcon, | |
| 20 | + type NewProjectScriptInput, | |
| 21 | + type ProjectScriptActionResult, | |
| 22 | + type ProjectScriptEditorRequest, | |
| 23 | +} from "./projectScriptEditor"; | |
| 46 | 24 | import { Button } from "./ui/button"; |
| 47 | −import { | |
| 48 | − Dialog, | |
| 49 | − DialogDescription, | |
| 50 | − DialogFooter, | |
| 51 | − DialogHeader, | |
| 52 | − DialogPanel, | |
| 53 | − DialogPopup, | |
| 54 | − DialogTitle, | |
| 55 | −} from "./ui/dialog"; | |
| 56 | 25 | import { Group, GroupSeparator } from "./ui/group"; |
| 57 | −import { Input } from "./ui/input"; | |
| 58 | −import { Label } from "./ui/label"; | |
| 59 | 26 | import { |
| 60 | 27 | Menu, |
| 61 | 28 | MenuGroup, |
| @@ -66,48 +33,9 @@ import { · 4 unmodified lines | ||
| 66 | 33 | MenuShortcut, |
| 67 | 34 | MenuTrigger, |
| 68 | 35 | } from "./ui/menu"; |
| 69 | −import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; | |
| 70 | −import { Switch } from "./ui/switch"; | |
| 71 | −import { Textarea } from "./ui/textarea"; | |
| 72 | 36 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 73 | 37 | |
| 74 | −const SCRIPT_ICONS: Array<{ id: ProjectScriptIcon; label: string }> = [ | |
| 75 | − { id: "play", label: "Play" }, | |
| 76 | − { id: "test", label: "Test" }, | |
| 77 | − { id: "lint", label: "Lint" }, | |
| 78 | − { id: "configure", label: "Configure" }, | |
| 79 | − { id: "build", label: "Build" }, | |
| 80 | − { id: "debug", label: "Debug" }, | |
| 81 | −]; | |
| 82 | − | |
| 83 | −function ScriptIcon({ | |
| 84 | − icon, | |
| 85 | − className = "size-3.5", | |
| 86 | −}: { | |
| 87 | − icon: ProjectScriptIcon; | |
| 88 | − className?: string; | |
| 89 | −}) { | |
| 90 | − if (icon === "test") return <FlaskConicalIcon className={className} />; | |
| 91 | − if (icon === "lint") return <ListChecksIcon className={className} />; | |
| 92 | − if (icon === "configure") return <WrenchIcon className={className} />; | |
| 93 | − if (icon === "build") return <HammerIcon className={className} />; | |
| 94 | − if (icon === "debug") return <BugIcon className={className} />; | |
| 95 | − return <PlayIcon className={className} />; | |
| 96 | −} | |
| 97 | − | |
| 98 | −export interface NewProjectScriptInput { | |
| 99 | − name: string; | |
| 100 | − command: string; | |
| 101 | − icon: ProjectScriptIcon; | |
| 102 | − runOnWorktreeCreate: boolean; | |
| 103 | − keybinding: string | null; | |
| 104 | − /** Optional URL to open in the in-app preview when this script runs. */ | |
| 105 | − previewUrl: string | null; | |
| 106 | − /** When true, automatically open the preview panel pointed at `previewUrl`. */ | |
| 107 | − autoOpenPreview: boolean; | |
| 108 | −} | |
| 109 | − | |
| 110 | −export type ProjectScriptActionResult = AtomCommandResult<void, unknown>; | |
| 38 | +export type { NewProjectScriptInput, ProjectScriptActionResult }; | |
| 111 | 39 | |
| 112 | 40 | const NO_FILE_SCRIPTS: ReadonlyArray<T3ProjectFileScript> = []; |
| 113 | 41 | |
| @@ -136,23 +64,11 @@ export default function ProjectScriptsControl({ · 22 unmodified lines | ||
| 136 | 64 | onUpdateScript, |
| 137 | 65 | onDeleteScript, |
| 138 | 66 | }: ProjectScriptsControlProps) { |
| 139 | − const addScriptFormId = React.useId(); | |
| 140 | − const [editingScriptId, setEditingScriptId] = useState<string | null>(null); | |
| 141 | 67 | const [actionsMenuOpen, setActionsMenuOpen] = useState({ |
| 142 | 68 | scripts: false, |
| 143 | 69 | imports: false, |
| 144 | 70 | }); |
| 145 | − const [dialogOpen, setDialogOpen] = useState(false); | |
| 146 | − const [name, setName] = useState(""); | |
| 147 | − const [command, setCommand] = useState(""); | |
| 148 | − const [icon, setIcon] = useState<ProjectScriptIcon>("play"); | |
| 149 | − const [iconPickerOpen, setIconPickerOpen] = useState(false); | |
| 150 | − const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false); | |
| 151 | − const [keybinding, setKeybinding] = useState(""); | |
| 152 | − const [previewUrl, setPreviewUrl] = useState(""); | |
| 153 | − const [autoOpenPreview, setAutoOpenPreview] = useState(false); | |
| 154 | − const [validationError, setValidationError] = useState<string | null>(null); | |
| 155 | − const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); | |
| 71 | + const [editorRequest, setEditorRequest] = useState<ProjectScriptEditorRequest | null>(null); | |
| 156 | 72 | |
| 157 | 73 | const primaryScript = useMemo(() => { |
| 158 | 74 | if (preferredScriptId) { |
| @@ -173,112 +89,23 @@ export default function ProjectScriptsControl({ · 14 unmodified lines | ||
| 173 | 89 | ), |
| 174 | 90 | [fileScripts, scripts], |
| 175 | 91 | ); |
| 176 | − const isEditing = editingScriptId !== null; | |
| 177 | 92 | const dropdownItemClassName = |
| 178 | 93 | "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"; |
| 179 | 94 | |
| 180 | − const captureKeybinding = (event: KeyboardEvent<HTMLInputElement>) => { | |
| 181 | − if (event.key === "Tab") return; | |
| 182 | − event.preventDefault(); | |
| 183 | − if (event.key === "Backspace" || event.key === "Delete") { | |
| 184 | − setKeybinding(""); | |
| 185 | − return; | |
| 186 | − } | |
| 187 | − const next = keybindingFromKeyboardEvent(event, navigator.platform); | |
| 188 | − if (!next) return; | |
| 189 | − setKeybinding(next); | |
| 190 | − }; | |
| 191 | − | |
| 192 | − const submitAddScript = async (event: FormEvent) => { | |
| 193 | − event.preventDefault(); | |
| 194 | − const trimmedName = name.trim(); | |
| 195 | − const trimmedCommand = command.trim(); | |
| 196 | − if (trimmedName.length === 0) { | |
| 197 | − setValidationError("Name is required."); | |
| 198 | − return; | |
| 199 | − } | |
| 200 | − if (trimmedCommand.length === 0) { | |
| 201 | − setValidationError("Command is required."); | |
| 202 | − return; | |
| 203 | − } | |
| 204 | − | |
| 205 | − setValidationError(null); | |
| 206 | − let payload: NewProjectScriptInput; | |
| 207 | − try { | |
| 208 | − const scriptIdForValidation = | |
| 209 | − editingScriptId ?? | |
| 210 | − nextProjectScriptId( | |
| 211 | − trimmedName, | |
| 212 | − scripts.map((script) => script.id), | |
| 213 | − ); | |
| 214 | − const keybindingRule = decodeProjectScriptKeybindingRule({ | |
| 215 | − keybinding, | |
| 216 | − command: commandForProjectScript(scriptIdForValidation), | |
| 217 | − }); | |
| 218 | − const trimmedPreviewUrl = previewUrl.trim(); | |
| 219 | − payload = { | |
| 220 | − name: trimmedName, | |
| 221 | − command: trimmedCommand, | |
| 222 | − icon, | |
| 223 | − runOnWorktreeCreate, | |
| 224 | − keybinding: keybindingRule?.key ?? null, | |
| 225 | − previewUrl: trimmedPreviewUrl.length > 0 ? trimmedPreviewUrl : null, | |
| 226 | − autoOpenPreview: trimmedPreviewUrl.length > 0 ? autoOpenPreview : false, | |
| 227 | − } satisfies NewProjectScriptInput; | |
| 228 | − } catch (error) { | |
| 229 | − setValidationError(error instanceof Error ? error.message : "Failed to save action."); | |
| 230 | − return; | |
| 231 | − } | |
| 232 | − | |
| 233 | − const result = editingScriptId | |
| 234 | − ? await onUpdateScript(editingScriptId, payload) | |
| 235 | − : await onAddScript(payload); | |
| 236 | − if (result._tag === "Failure") { | |
| 237 | − if (!isAtomCommandInterrupted(result)) { | |
| 238 | − const error = squashAtomCommandFailure(result); | |
| 239 | − setValidationError(error instanceof Error ? error.message : "Failed to save action."); | |
| 240 | − } | |
| 241 | − return; | |
| 242 | − } | |
| 243 | − setDialogOpen(false); | |
| 244 | − setIconPickerOpen(false); | |
| 245 | − }; | |
| 246 | − | |
| 247 | 95 | const openAddDialog = () => { |
| 248 | − setEditingScriptId(null); | |
| 249 | − setName(""); | |
| 250 | − setCommand(""); | |
| 251 | − setIcon("play"); | |
| 252 | − setIconPickerOpen(false); | |
| 253 | − setRunOnWorktreeCreate(false); | |
| 254 | − setKeybinding(""); | |
| 255 | − setPreviewUrl(""); | |
| 256 | − setAutoOpenPreview(false); | |
| 257 | − setValidationError(null); | |
| 258 | − setDialogOpen(true); | |
| 96 | + setEditorRequest({ scriptId: null, initial: EMPTY_PROJECT_SCRIPT_INPUT }); | |
| 259 | 97 | }; |
| 260 | 98 | |
| 261 | 99 | const openEditDialog = (script: ProjectScript) => { |
| 262 | 100 | setActionsMenuOpen({ scripts: false, imports: false }); |
| 263 | − setEditingScriptId(script.id); | |
| 264 | − setName(script.name); | |
| 265 | − setCommand(script.command); | |
| 266 | − setIcon(script.icon); | |
| 267 | − setIconPickerOpen(false); | |
| 268 | − setRunOnWorktreeCreate(script.runOnWorktreeCreate); | |
| 269 | − setKeybinding(keybindingValueForCommand(keybindings, commandForProjectScript(script.id)) ?? ""); | |
| 270 | − setPreviewUrl(script.previewUrl ?? ""); | |
| 271 | − setAutoOpenPreview(script.autoOpenPreview ?? false); | |
| 272 | − setValidationError(null); | |
| 273 | − setDialogOpen(true); | |
| 101 | + setEditorRequest(editorRequestForScript(script, keybindings)); | |
| 274 | 102 | }; |
| 275 | 103 | |
| 276 | − const confirmDeleteScript = useCallback(() => { | |
| 277 | − if (!editingScriptId) return; | |
| 278 | − setDeleteConfirmOpen(false); | |
| 279 | − setDialogOpen(false); | |
| 280 | − void onDeleteScript(editingScriptId); | |
| 281 | − }, [editingScriptId, onDeleteScript]); | |
| 104 | + const submitScript = useCallback( | |
| 105 | + (scriptId: string | null, input: NewProjectScriptInput) => | |
| 106 | + scriptId === null ? onAddScript(input) : onUpdateScript(scriptId, input), | |
| 107 | + [onAddScript, onUpdateScript], | |
| 108 | + ); | |
| 282 | 109 | |
| 283 | 110 | const importFileScript = async (fileScript: T3ProjectFileScript) => { |
| 284 | 111 | const payload: NewProjectScriptInput = { |
| @@ -295,17 +122,11 @@ export default function ProjectScriptsControl({ · 10 unmodified lines | ||
| 295 | 122 | // Surface the failure through the regular add dialog, prefilled so the |
| 296 | 123 | // user can adjust and retry. |
| 297 | 124 | const error = squashAtomCommandFailure(result); |
| 298 | − setEditingScriptId(null); | |
| 299 | − setName(payload.name); | |
| 300 | − setCommand(payload.command); | |
| 301 | − setIcon(payload.icon); | |
| 302 | − setIconPickerOpen(false); | |
| 303 | − setRunOnWorktreeCreate(payload.runOnWorktreeCreate); | |
| 304 | − setKeybinding(""); | |
| 305 | − setPreviewUrl(payload.previewUrl ?? ""); | |
| 306 | − setAutoOpenPreview(payload.autoOpenPreview); | |
| 307 | − setValidationError(error instanceof Error ? error.message : "Failed to import action."); | |
| 308 | − setDialogOpen(true); | |
| 125 | + setEditorRequest({ | |
| 126 | + scriptId: null, | |
| 127 | + initial: payload, | |
| 128 | + error: error instanceof Error ? error.message : "Failed to import action.", | |
| 129 | + }); | |
| 309 | 130 | } |
| 310 | 131 | }; |
| 311 | 132 | |
| @@ -466,184 +287,13 @@ export default function ProjectScriptsControl({ · 154 unmodified lines | ||
| 466 | 287 | </Tooltip> |
| 467 | 288 | )} |
| 468 | 289 | |
| 469 | − <Dialog | |
| 470 | − onOpenChange={(open) => { | |
| 471 | − setDialogOpen(open); | |
| 472 | − if (!open) { | |
| 473 | − setIconPickerOpen(false); | |
| 474 | − } | |
| 475 | − }} | |
| 476 | − onOpenChangeComplete={(open) => { | |
| 477 | − if (open) return; | |
| 478 | − setEditingScriptId(null); | |
| 479 | − setName(""); | |
| 480 | − setCommand(""); | |
| 481 | − setIcon("play"); | |
| 482 | − setRunOnWorktreeCreate(false); | |
| 483 | − setKeybinding(""); | |
| 484 | − setPreviewUrl(""); | |
| 485 | − setAutoOpenPreview(false); | |
| 486 | − setValidationError(null); | |
| 487 | − }} | |
| 488 | − open={dialogOpen} | |
| 489 | − > | |
| 490 | − <DialogPopup> | |
| 491 | − <DialogHeader> | |
| 492 | − <DialogTitle>{isEditing ? "Edit Action" : "Add Action"}</DialogTitle> | |
| 493 | − <DialogDescription> | |
| 494 | − Actions are project-scoped commands you can run from the top bar or keybindings. | |
| 495 | − </DialogDescription> | |
| 496 | − </DialogHeader> | |
| 497 | − <DialogPanel> | |
| 498 | − <form id={addScriptFormId} className="space-y-4" onSubmit={submitAddScript}> | |
| 499 | − <div className="space-y-1.5"> | |
| 500 | − <Label htmlFor="script-name">Name</Label> | |
| 501 | − <div className="flex items-center gap-2"> | |
| 502 | − <Popover onOpenChange={setIconPickerOpen} open={iconPickerOpen}> | |
| 503 | − <PopoverTrigger | |
| 504 | − render={ | |
| 505 | − <Button | |
| 506 | − type="button" | |
| 507 | − variant="outline" | |
| 508 | − 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" | |
| 509 | − aria-label="Choose icon" | |
| 510 | − /> | |
| 511 | − } | |
| 512 | − > | |
| 513 | − <ScriptIcon icon={icon} className="size-4.5" /> | |
| 514 | − </PopoverTrigger> | |
| 515 | − <PopoverPopup align="start"> | |
| 516 | − <div className="grid grid-cols-3 gap-2"> | |
| 517 | − {SCRIPT_ICONS.map((entry) => { | |
| 518 | − const isSelected = entry.id === icon; | |
| 519 | − return ( | |
| 520 | − <button | |
| 521 | − key={entry.id} | |
| 522 | − type="button" | |
| 523 | − className={`relative flex flex-col items-center gap-2 rounded-md border px-2 py-2 text-xs dark:border-transparent ${ | |
| 524 | − isSelected | |
| 525 | − ? "border-primary/70 bg-primary/10 dark:ring-1 dark:ring-primary/30" | |
| 526 | − : "border-border/70 hover:bg-accent/60 dark:bg-white/[0.035]" | |
| 527 | − }`} | |
| 528 | − onClick={() => { | |
| 529 | − setIcon(entry.id); | |
| 530 | − setIconPickerOpen(false); | |
| 531 | − }} | |
| 532 | − > | |
| 533 | − <ScriptIcon icon={entry.id} className="size-4" /> | |
| 534 | − <span>{entry.label}</span> | |
| 535 | − </button> | |
| 536 | − ); | |
| 537 | − })} | |
| 538 | − </div> | |
| 539 | − </PopoverPopup> | |
| 540 | − </Popover> | |
| 541 | − <Input | |
| 542 | − id="script-name" | |
| 543 | − autoFocus | |
| 544 | − placeholder="Test" | |
| 545 | − value={name} | |
| 546 | − onChange={(event) => setName(event.target.value)} | |
| 547 | − /> | |
| 548 | − </div> | |
| 549 | − </div> | |
| 550 | − <div className="space-y-1.5"> | |
| 551 | − <Label htmlFor="script-keybinding">Keybinding</Label> | |
| 552 | − <Input | |
| 553 | − id="script-keybinding" | |
| 554 | − placeholder="Press shortcut" | |
| 555 | − value={keybinding} | |
| 556 | − readOnly | |
| 557 | − onKeyDown={captureKeybinding} | |
| 558 | − /> | |
| 559 | − <p className="text-xs text-muted-foreground"> | |
| 560 | − Press a shortcut. Use <code>Backspace</code> to clear. | |
| 561 | − </p> | |
| 562 | − </div> | |
| 563 | − <div className="space-y-1.5"> | |
| 564 | − <Label htmlFor="script-command">Command</Label> | |
| 565 | − <Textarea | |
| 566 | − id="script-command" | |
| 567 | − placeholder="bun test" | |
| 568 | − value={command} | |
| 569 | − onChange={(event) => setCommand(event.target.value)} | |
| 570 | − /> | |
| 571 | − </div> | |
| 572 | − <div className="space-y-1.5"> | |
| 573 | − <Label htmlFor="script-preview-url">Preview URL (optional)</Label> | |
| 574 | − <Input | |
| 575 | − id="script-preview-url" | |
| 576 | − placeholder="http://localhost:5173" | |
| 577 | − value={previewUrl} | |
| 578 | − onChange={(event) => setPreviewUrl(event.target.value)} | |
| 579 | − /> | |
| 580 | − <p className="text-xs text-muted-foreground"> | |
| 581 | − Open this URL in the in-app preview when this action runs. | |
| 582 | − </p> | |
| 583 | − </div> | |
| 584 | − <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]"> | |
| 585 | − <span>Run automatically on worktree creation</span> | |
| 586 | − <Switch | |
| 587 | − checked={runOnWorktreeCreate} | |
| 588 | − onCheckedChange={(checked) => setRunOnWorktreeCreate(Boolean(checked))} | |
| 589 | − /> | |
| 590 | − </label> | |
| 591 | − <label | |
| 592 | − 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] ${ | |
| 593 | − previewUrl.trim().length === 0 ? "opacity-60" : "" | |
| 594 | − }`} | |
| 595 | − > | |
| 596 | − <span>Open preview automatically when this action runs</span> | |
| 597 | − <Switch | |
| 598 | − checked={autoOpenPreview} | |
| 599 | − disabled={previewUrl.trim().length === 0} | |
| 600 | − onCheckedChange={(checked) => setAutoOpenPreview(Boolean(checked))} | |
| 601 | − /> | |
| 602 | − </label> | |
| 603 | − {validationError && <p className="text-sm text-destructive">{validationError}</p>} | |
| 604 | − </form> | |
| 605 | − </DialogPanel> | |
| 606 | − <DialogFooter className="dark:border-transparent dark:bg-transparent"> | |
| 607 | − {isEditing && ( | |
| 608 | − <Button | |
| 609 | − type="button" | |
| 610 | − variant="destructive-outline" | |
| 611 | − className="mr-auto" | |
| 612 | − onClick={() => setDeleteConfirmOpen(true)} | |
| 613 | − > | |
| 614 | − Delete | |
| 615 | − </Button> | |
| 616 | − )} | |
| 617 | − <Button | |
| 618 | − type="button" | |
| 619 | − variant="outline" | |
| 620 | − onClick={() => { | |
| 621 | − setDialogOpen(false); | |
| 622 | − }} | |
| 623 | − > | |
| 624 | − Cancel | |
| 625 | − </Button> | |
| 626 | − <Button form={addScriptFormId} type="submit"> | |
| 627 | − {isEditing ? "Save changes" : "Save action"} | |
| 628 | − </Button> | |
| 629 | − </DialogFooter> | |
| 630 | − </DialogPopup> | |
| 631 | − </Dialog> | |
| 632 | − | |
| 633 | − <AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}> | |
| 634 | − <AlertDialogPopup> | |
| 635 | − <AlertDialogHeader> | |
| 636 | − <AlertDialogTitle>Delete action "{name}"?</AlertDialogTitle> | |
| 637 | − <AlertDialogDescription>This action cannot be undone.</AlertDialogDescription> | |
| 638 | − </AlertDialogHeader> | |
| 639 | − <AlertDialogFooter> | |
| 640 | − <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose> | |
| 641 | − <Button variant="destructive" onClick={confirmDeleteScript}> | |
| 642 | − Delete action | |
| 643 | − </Button> | |
| 644 | − </AlertDialogFooter> | |
| 645 | − </AlertDialogPopup> | |
| 646 | − </AlertDialog> | |
| 290 | + <ProjectScriptEditorDialog | |
| 291 | + request={editorRequest} | |
| 292 | + scripts={scripts} | |
| 293 | + onSubmit={submitScript} | |
| 294 | + onDelete={(scriptId) => void onDeleteScript(scriptId)} | |
| 295 | + onClose={() => setEditorRequest(null)} | |
| 296 | + /> | |
| 647 | 297 | </> |
| 648 | 298 | ); |
| 649 | 299 | } |
| @@ -28,7 +28,7 @@ import { · 27 unmodified lines | ||
| 28 | 28 | scopeThreadRef, |
| 29 | 29 | scopedThreadKey, |
| 30 | 30 | } from "@t3tools/client-runtime/environment"; |
| 31 | −import type { ScopedThreadRef, SidebarProjectGroupingMode } from "@t3tools/contracts"; | |
| 31 | +import type { ScopedThreadRef } from "@t3tools/contracts"; | |
| 32 | 32 | import type { TimestampFormat } from "@t3tools/contracts/settings"; |
| 33 | 33 | import { |
| 34 | 34 | AlarmClockIcon, |
| @@ -39,7 +39,6 @@ import { · 4 unmodified lines | ||
| 39 | 39 | CircleCheckIcon, |
| 40 | 40 | CircleDashedIcon, |
| 41 | 41 | ClockIcon, |
| 42 | − CopyIcon, | |
| 43 | 42 | FolderIcon, |
| 44 | 43 | FolderPlusIcon, |
| 45 | 44 | GitBranchIcon, |
| @@ -51,7 +50,6 @@ import { · 5 unmodified lines | ||
| 51 | 50 | ServerIcon, |
| 52 | 51 | SquarePenIcon, |
| 53 | 52 | TerminalIcon, |
| 54 | − Trash2Icon, | |
| 55 | 53 | Undo2Icon, |
| 56 | 54 | XIcon, |
| 57 | 55 | } from "lucide-react"; |
| @@ -89,14 +87,9 @@ import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../termina · 31 unmodified lines | ||
| 89 | 87 | import { isMacPlatform } from "~/lib/utils"; |
| 90 | 88 | import { useOpenPrLink } from "../lib/openPullRequestLink"; |
| 91 | 89 | import { readLocalApi } from "../localApi"; |
| 92 | −import { | |
| 93 | − deriveProjectGroupingOverrideKey, | |
| 94 | − getProjectOrderKey, | |
| 95 | − selectProjectGroupingSettings, | |
| 96 | −} from "../logicalProject"; | |
| 90 | +import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject"; | |
| 97 | 91 | import { |
| 98 | 92 | buildSidebarProjectSnapshots, |
| 99 | − type SidebarProjectGroupMember, | |
| 100 | 93 | type SidebarProjectSnapshot, |
| 101 | 94 | } from "../sidebarProjectGrouping"; |
| 102 | 95 | import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; |
| @@ -105,7 +98,7 @@ import { useThreadActions } from "../hooks/useThreadActions"; · 2 unmodified lines | ||
| 105 | 98 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; |
| 106 | 99 | import { openCommandPalette } from "../commandPaletteBus"; |
| 107 | 100 | import { startNewThreadFromContext } from "../lib/chatThreadActions"; |
| 108 | −import { useClientSettings, useUpdateClientSettings } from "../hooks/useSettings"; | |
| 101 | +import { useClientSettings } from "../hooks/useSettings"; | |
| 109 | 102 | import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; |
| 110 | 103 | import { useNowMinute } from "../hooks/useNowMinute"; |
| 111 | 104 | import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; |
| @@ -113,7 +106,6 @@ import { useProjects, useThreadShells } from "../state/entities"; · 1 unmodified line | ||
| 113 | 106 | import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; |
| 114 | 107 | import { vcsEnvironment } from "../state/vcs"; |
| 115 | 108 | import { threadEnvironment } from "../state/threads"; |
| 116 | −import { projectEnvironment } from "../state/projects"; | |
| 117 | 109 | import { useEnvironmentQuery } from "../state/query"; |
| 118 | 110 | import { useAtomCommand } from "../state/use-atom-command"; |
| 119 | 111 | import { |
| @@ -138,7 +130,6 @@ import { · 18 unmodified lines | ||
| 138 | 130 | resolveSidebarThreadStatus, |
| 139 | 131 | searchSidebarThreadsByTitle, |
| 140 | 132 | resolveWorkingStartedAt, |
| 141 | − shouldNavigateAfterProjectRemoval, | |
| 142 | 133 | sortLogicalProjectsForSidebar, |
| 143 | 134 | sortPinnedThreadsForSidebar, |
| 144 | 135 | sortSettledThreadsForSidebar, |
| @@ -166,18 +157,8 @@ import { primaryServerProvidersAtom } from "../state/server"; · 21 unmodified lines | ||
| 166 | 157 | import { useThreadRunningTerminalIds } from "../state/terminalSessions"; |
| 167 | 158 | import { stackedThreadToast, toastManager } from "./ui/toast"; |
| 168 | 159 | import { Button } from "./ui/button"; |
| 169 | −import { | |
| 170 | − Dialog, | |
| 171 | − DialogDescription, | |
| 172 | − DialogFooter, | |
| 173 | − DialogHeader, | |
| 174 | − DialogPanel, | |
| 175 | − DialogPopup, | |
| 176 | − DialogTitle, | |
| 177 | −} from "./ui/dialog"; | |
| 178 | 160 | import { Input } from "./ui/input"; |
| 179 | 161 | import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu"; |
| 180 | −import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; | |
| 181 | 162 | import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar"; |
| 182 | 163 | import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; |
| 183 | 164 | import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; |
| @@ -188,11 +169,6 @@ import { useComposerDraftStore } from "../composerDraftStore"; · 4 unmodified lines | ||
| 188 | 169 | // stays behind an explicit Show more. |
| 189 | 170 | const SETTLED_TAIL_INITIAL_COUNT = 10; |
| 190 | 171 | const SETTLED_TAIL_PAGE_COUNT = 25; |
| 191 | −const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = { | |
| 192 | − repository: "Group by repository", | |
| 193 | − repository_path: "Group by repository path", | |
| 194 | − separate: "Keep separate", | |
| 195 | −}; | |
| 196 | 172 | |
| 197 | 173 | function compactSidebarTimeLabel(label: string): string { |
| 198 | 174 | if (label === "just now") return "now"; |
| @@ -1379,13 +1355,6 @@ export default function Sidebar() { · 1180 unmodified lines | ||
| 1379 | 1355 | const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { |
| 1380 | 1356 | reportFailure: false, |
| 1381 | 1357 | }); |
| 1382 | − const deleteProject = useAtomCommand(projectEnvironment.delete, { | |
| 1383 | − reportFailure: false, | |
| 1384 | − }); | |
| 1385 | − const updateProject = useAtomCommand(projectEnvironment.update, { | |
| 1386 | − reportFailure: false, | |
| 1387 | − }); | |
| 1388 | − const updateSettings = useUpdateClientSettings(); | |
| 1389 | 1358 | const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({ |
| 1390 | 1359 | onCopy: ({ path }) => { |
| 1391 | 1360 | toastManager.add({ |
| @@ -1423,9 +1392,6 @@ export default function Sidebar() { · 31 unmodified lines | ||
| 1423 | 1392 | ); |
| 1424 | 1393 | }, |
| 1425 | 1394 | }); |
| 1426 | − const [projectActionsTarget, setProjectActionsTarget] = useState<SidebarProjectSnapshot | null>( | |
| 1427 | − null, | |
| 1428 | − ); | |
| 1429 | 1395 | const [projectScopeMenuOpen, setProjectScopeMenuOpen] = useState(false); |
| 1430 | 1396 | const newThreadContext = useHandleNewThread(); |
| 1431 | 1397 | const openAddProjectCommandPalette = useCallback( |
| @@ -1602,156 +1568,20 @@ export default function Sidebar() { · 170 unmodified lines | ||
| 1602 | 1568 | clearSelection(); |
| 1603 | 1569 | }, [clearSelection, projectScopeKey]); |
| 1604 | 1570 | |
| 1605 | − const handleRemoveProjectMembers = useCallback( | |
| 1606 | − async (projectGroup: SidebarProjectSnapshot, members: readonly SidebarProjectGroupMember[]) => { | |
| 1607 | − const api = readLocalApi(); | |
| 1608 | − if (!api) return; | |
| 1609 | − | |
| 1610 | − const memberKeys = new Set(members.map((member) => `${member.environmentId}:${member.id}`)); | |
| 1611 | − const projectThreads = threads.filter((thread) => | |
| 1612 | − memberKeys.has(`${thread.environmentId}:${thread.projectId}`), | |
| 1613 | − ); | |
| 1614 | − const isWholeGroup = members.length === projectGroup.memberProjects.length; | |
| 1615 | − const singleMember = members.length === 1 ? members[0]! : null; | |
| 1616 | − const targetLabel = singleMember?.title ?? projectGroup.displayName; | |
| 1617 | − const confirmed = await settlePromise(() => | |
| 1618 | − api.dialogs.confirm( | |
| 1619 | − projectThreads.length > 0 | |
| 1620 | − ? [ | |
| 1621 | − `Remove project "${targetLabel}" and delete its ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}?`, | |
| 1622 | − ...(singleMember | |
| 1623 | − ? [ | |
| 1624 | − `Path: ${singleMember.workspaceRoot}`, | |
| 1625 | − ...(singleMember.environmentLabel | |
| 1626 | − ? [`Environment: ${singleMember.environmentLabel}`] | |
| 1627 | − : []), | |
| 1628 | − ] | |
| 1629 | − : [`This removes ${members.length} grouped project entries.`]), | |
| 1630 | − "This permanently clears conversation history for those threads.", | |
| 1631 | − isWholeGroup | |
| 1632 | − ? "This removes only the project entries, not the files on disk." | |
| 1633 | − : "Other entries in this grouped project are unaffected.", | |
| 1634 | − "This action cannot be undone.", | |
| 1635 | − ].join("\n") | |
| 1636 | − : [ | |
| 1637 | − `Remove project "${targetLabel}"?`, | |
| 1638 | − ...(singleMember | |
| 1639 | − ? [ | |
| 1640 | − `Path: ${singleMember.workspaceRoot}`, | |
| 1641 | − ...(singleMember.environmentLabel | |
| 1642 | − ? [`Environment: ${singleMember.environmentLabel}`] | |
| 1643 | − : []), | |
| 1644 | − ] | |
| 1645 | − : [`This removes ${members.length} grouped project entries.`]), | |
| 1646 | − isWholeGroup | |
| 1647 | − ? "This removes only the project entries, not the files on disk." | |
| 1648 | − : "Other entries in this grouped project are unaffected.", | |
| 1649 | − ].join("\n"), | |
| 1650 | − ), | |
| 1651 | − ); | |
| 1652 | − if (confirmed._tag === "Failure" || !confirmed.value) return; | |
| 1653 | − | |
| 1654 | − const draftStore = useComposerDraftStore.getState(); | |
| 1655 | − let shouldNavigate = false; | |
| 1656 | − for (const project of members) { | |
| 1657 | − const memberThreads = projectThreads.filter( | |
| 1658 | − (thread) => | |
| 1659 | − thread.environmentId === project.environmentId && thread.projectId === project.id, | |
| 1660 | − ); | |
| 1661 | − const projectRef = scopeProjectRef(project.environmentId, project.id); | |
| 1662 | − const projectDraftThread = draftStore.getDraftThreadByProjectRef(projectRef); | |
| 1663 | − const memberRemovalNeedsNavigation = shouldNavigateAfterProjectRemoval({ | |
| 1664 | − routeTarget: routeTargetRef.current, | |
| 1665 | − projectThreads: memberThreads, | |
| 1666 | − projectDraftId: projectDraftThread?.draftId ?? null, | |
| 1667 | − }); | |
| 1668 | − | |
| 1669 | − const result = await deleteProject({ | |
| 1670 | − environmentId: project.environmentId, | |
| 1671 | − input: { | |
| 1672 | − projectId: project.id, | |
| 1673 | − ...(memberThreads.length > 0 ? { force: true } : {}), | |
| 1674 | − }, | |
| 1675 | − }); | |
| 1676 | − if (result._tag === "Failure") { | |
| 1677 | − if (!isAtomCommandInterrupted(result)) { | |
| 1678 | − const error = squashAtomCommandFailure(result); | |
| 1679 | − toastManager.add( | |
| 1680 | − stackedThreadToast({ | |
| 1681 | − type: "error", | |
| 1682 | − title: `Failed to remove "${project.title}"`, | |
| 1683 | − description: error instanceof Error ? error.message : "An error occurred.", | |
| 1684 | − }), | |
| 1685 | − ); | |
| 1686 | − } | |
| 1687 | − if (shouldNavigate) { | |
| 1688 | − void router.navigate({ to: "/" }); | |
| 1689 | − } | |
| 1690 | − return; | |
| 1691 | − } | |
| 1692 | − | |
| 1693 | − shouldNavigate ||= memberRemovalNeedsNavigation; | |
| 1694 | − if (projectDraftThread) { | |
| 1695 | − draftStore.clearDraftThread(projectDraftThread.draftId); | |
| 1696 | − } | |
| 1697 | − draftStore.clearProjectDraftThreadId(projectRef); | |
| 1698 | − } | |
| 1699 | − | |
| 1700 | − if (shouldNavigate) { | |
| 1701 | − void router.navigate({ to: "/" }); | |
| 1702 | − } | |
| 1703 | − }, | |
| 1704 | − [deleteProject, router, threads], | |
| 1705 | − ); | |
| 1706 | − | |
| 1707 | − const renameProjectMember = useCallback( | |
| 1708 | − async (member: SidebarProjectGroupMember, nextTitle: string) => { | |
| 1709 | − const title = nextTitle.trim(); | |
| 1710 | − if (!title) { | |
| 1711 | − toastManager.add({ type: "warning", title: "Project title cannot be empty" }); | |
| 1712 | − return; | |
| 1713 | − } | |
| 1714 | − if (title === member.title) return; | |
| 1715 | − const result = await updateProject({ | |
| 1716 | − environmentId: member.environmentId, | |
| 1717 | − input: { projectId: member.id, title }, | |
| 1718 | − }); | |
| 1719 | − if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { | |
| 1720 | − const error = squashAtomCommandFailure(result); | |
| 1721 | − toastManager.add( | |
| 1722 | − stackedThreadToast({ | |
| 1723 | − type: "error", | |
| 1724 | − title: "Failed to rename project", | |
| 1725 | − description: error instanceof Error ? error.message : "An error occurred.", | |
| 1726 | − }), | |
| 1727 | − ); | |
| 1728 | − } | |
| 1729 | − }, | |
| 1730 | − [updateProject], | |
| 1731 | − ); | |
| 1732 | − | |
| 1733 | − const updateProjectGroupingPreference = useCallback( | |
| 1734 | − (member: SidebarProjectGroupMember, selection: SidebarProjectGroupingMode | "inherit") => { | |
| 1735 | − const overrideKey = deriveProjectGroupingOverrideKey(member); | |
| 1736 | − const nextOverrides = { ...projectGroupingSettings.sidebarProjectGroupingOverrides }; | |
| 1737 | − if (selection === "inherit") { | |
| 1738 | − delete nextOverrides[overrideKey]; | |
| 1739 | − } else { | |
| 1740 | − nextOverrides[overrideKey] = selection; | |
| 1741 | − } | |
| 1742 | − updateSettings({ sidebarProjectGroupingOverrides: nextOverrides }); | |
| 1743 | − }, | |
| 1744 | − [projectGroupingSettings.sidebarProjectGroupingOverrides, updateSettings], | |
| 1745 | − ); | |
| 1746 | − | |
| 1747 | 1571 | const handleProjectActions = useCallback( |
| 1748 | 1572 | (event: ReactMouseEvent<HTMLButtonElement>, projectGroup: SidebarProjectSnapshot) => { |
| 1749 | 1573 | event.preventDefault(); |
| 1750 | 1574 | event.stopPropagation(); |
| 1751 | 1575 | setProjectScopeMenuOpen(false); |
| 1752 | − window.requestAnimationFrame(() => setProjectActionsTarget(projectGroup)); | |
| 1576 | + if (isMobile) { | |
| 1577 | + setOpenMobile(false); | |
| 1578 | + } | |
| 1579 | + void router.navigate({ | |
| 1580 | + to: "/settings/projects/$projectKey", | |
| 1581 | + params: { projectKey: projectGroup.projectKey }, | |
| 1582 | + }); | |
| 1753 | 1583 | }, |
| 1754 | − [], | |
| 1584 | + [isMobile, router, setOpenMobile], | |
| 1755 | 1585 | ); |
| 1756 | 1586 | |
| 1757 | 1587 | // Settled threads stay in the live shell stream (settled ≠ archived), so |
| @@ -3545,190 +3375,6 @@ export default function Sidebar() { · 1787 unmodified lines | ||
| 3545 | 3375 | ) : null} |
| 3546 | 3376 | </SidebarGroup> |
| 3547 | 3377 | </SidebarContent> |
| 3548 | − <Dialog | |
| 3549 | − open={projectActionsTarget !== null} | |
| 3550 | − onOpenChange={(open) => { | |
| 3551 | − if (!open) setProjectActionsTarget(null); | |
| 3552 | − }} | |
| 3553 | − > | |
| 3554 | − <DialogPopup className="max-w-xl"> | |
| 3555 | − <DialogHeader className="gap-3 pb-1!"> | |
| 3556 | − <DialogTitle className="text-balance">Project settings</DialogTitle> | |
| 3557 | − <DialogDescription className="sr-only"> | |
| 3558 | − Manage project names, grouping rules, and environments. | |
| 3559 | − </DialogDescription> | |
| 3560 | − <div className="grid gap-1.5 text-base text-muted-foreground"> | |
| 3561 | − {projectActionsTarget?.memberProjects.map((member) => ( | |
| 3562 | − <div key={member.physicalProjectKey} className="flex min-w-0 items-center gap-3"> | |
| 3563 | − <span className="flex min-w-0 items-center gap-1"> | |
| 3564 | − <FolderIcon className="size-3.5 shrink-0 opacity-60" /> | |
| 3565 | − <span className="min-w-0 truncate font-mono">{member.workspaceRoot}</span> | |
| 3566 | − <Button | |
| 3567 | − size="icon-xs" | |
| 3568 | − variant="ghost" | |
| 3569 | − className="size-4 shrink-0 rounded-sm" | |
| 3570 | − aria-label="Copy project path" | |
| 3571 | − title="Copy project path" | |
| 3572 | − onClick={() => | |
| 3573 | − copyPathToClipboard(member.workspaceRoot, { path: member.workspaceRoot }) | |
| 3574 | − } | |
| 3575 | − > | |
| 3576 | − <CopyIcon className="size-3.5" /> | |
| 3577 | − </Button> | |
| 3578 | − </span> | |
| 3579 | − <span className="flex min-w-0 shrink-0 items-center gap-1"> | |
| 3580 | − <ServerIcon className="size-3.5 shrink-0 opacity-60" /> | |
| 3581 | − <span className="min-w-0 truncate"> | |
| 3582 | − {member.environmentLabel ?? "Current environment"} | |
| 3583 | − </span> | |
| 3584 | − </span> | |
| 3585 | − </div> | |
| 3586 | − ))} | |
| 3587 | − </div> | |
| 3588 | − </DialogHeader> | |
| 3589 | − <DialogPanel className="p-0"> | |
| 3590 | − <div className="divide-y divide-border/60"> | |
| 3591 | − {projectActionsTarget?.memberProjects.map((member) => ( | |
| 3592 | − <section | |
| 3593 | − key={member.physicalProjectKey} | |
| 3594 | − className="grid min-w-0 gap-5 px-6 pb-5 pt-2 sm:gap-4 sm:pb-4 sm:pt-2" | |
| 3595 | − > | |
| 3596 | − <div className="grid gap-4 sm:grid-cols-2 sm:gap-3"> | |
| 3597 | − <label className="grid min-w-0 gap-1.5"> | |
| 3598 | − <span className="font-medium text-foreground">Project name</span> | |
| 3599 | − <Input | |
| 3600 | − key={`${member.physicalProjectKey}:${member.title}`} | |
| 3601 | − aria-label={`Project name in ${member.environmentLabel ?? "current environment"}`} | |
| 3602 | − defaultValue={member.title} | |
| 3603 | − onBlur={(event) => { | |
| 3604 | − void renameProjectMember(member, event.currentTarget.value); | |
| 3605 | − }} | |
| 3606 | − onKeyDown={(event) => { | |
| 3607 | − if (event.key === "Enter") event.currentTarget.blur(); | |
| 3608 | − }} | |
| 3609 | − /> | |
| 3610 | − </label> | |
| 3611 | − <label className="grid min-w-0 gap-1.5"> | |
| 3612 | − <span className="font-medium text-foreground">Grouping rule</span> | |
| 3613 | − <Select | |
| 3614 | − value={ | |
| 3615 | − projectGroupingSettings.sidebarProjectGroupingOverrides?.[ | |
| 3616 | − deriveProjectGroupingOverrideKey(member) | |
| 3617 | − ] ?? "inherit" | |
| 3618 | − } | |
| 3619 | − onValueChange={(value) => { | |
| 3620 | − if ( | |
| 3621 | − value === "inherit" || | |
| 3622 | − value === "repository" || | |
| 3623 | − value === "repository_path" || | |
| 3624 | − value === "separate" | |
| 3625 | − ) { | |
| 3626 | − updateProjectGroupingPreference(member, value); | |
| 3627 | − } | |
| 3628 | − }} | |
| 3629 | − > | |
| 3630 | − <SelectTrigger | |
| 3631 | − className="w-full sm:min-h-7.5" | |
| 3632 | − aria-label={`Grouping rule for ${member.environmentLabel ?? "current environment"}`} | |
| 3633 | − > | |
| 3634 | − <SelectValue> | |
| 3635 | − {(() => { | |
| 3636 | − const selection = | |
| 3637 | − projectGroupingSettings.sidebarProjectGroupingOverrides?.[ | |
| 3638 | − deriveProjectGroupingOverrideKey(member) | |
| 3639 | − ] ?? "inherit"; | |
| 3640 | − return selection === "inherit" | |
| 3641 | − ? `Default (${PROJECT_GROUPING_MODE_LABELS[projectGroupingSettings.sidebarProjectGroupingMode]})` | |
| 3642 | − : PROJECT_GROUPING_MODE_LABELS[selection]; | |
| 3643 | − })()} | |
| 3644 | − </SelectValue> | |
| 3645 | − </SelectTrigger> | |
| 3646 | − <SelectPopup align="start" alignItemWithTrigger={false}> | |
| 3647 | − <SelectItem hideIndicator value="inherit"> | |
| 3648 | − Use global default | |
| 3649 | − </SelectItem> | |
| 3650 | − <SelectItem hideIndicator value="repository"> | |
| 3651 | − {PROJECT_GROUPING_MODE_LABELS.repository} | |
| 3652 | − </SelectItem> | |
| 3653 | − <SelectItem hideIndicator value="repository_path"> | |
| 3654 | − {PROJECT_GROUPING_MODE_LABELS.repository_path} | |
| 3655 | − </SelectItem> | |
| 3656 | − <SelectItem hideIndicator value="separate"> | |
| 3657 | − {PROJECT_GROUPING_MODE_LABELS.separate} | |
| 3658 | − </SelectItem> | |
| 3659 | − </SelectPopup> | |
| 3660 | − </Select> | |
| 3661 | − </label> | |
| 3662 | − </div> | |
| 3663 | − {projectActionsTarget.memberProjects.length > 1 ? ( | |
| 3664 | − <div className="flex justify-end"> | |
| 3665 | − <Button | |
| 3666 | − size="sm" | |
| 3667 | − variant="ghost" | |
| 3668 | − className="text-destructive-foreground hover:bg-destructive/8 hover:text-destructive-foreground" | |
| 3669 | − onClick={() => { | |
| 3670 | − const projectGroup = projectActionsTarget; | |
| 3671 | − setProjectActionsTarget(null); | |
| 3672 | − void handleRemoveProjectMembers(projectGroup, [member]); | |
| 3673 | − }} | |
| 3674 | − > | |
| 3675 | − <Trash2Icon /> | |
| 3676 | − Remove project | |
| 3677 | − </Button> | |
| 3678 | − </div> | |
| 3679 | − ) : null} | |
| 3680 | − </section> | |
| 3681 | − ))} | |
| 3682 | − </div> | |
| 3683 | − {projectActionsTarget && projectActionsTarget.memberProjects.length > 1 ? ( | |
| 3684 | − <div className="flex flex-col gap-3 border-t border-border/60 bg-muted/32 px-6 py-4 sm:flex-row sm:items-center sm:justify-between"> | |
| 3685 | − <div className="min-w-0"> | |
| 3686 | − <p className="text-base font-medium text-foreground sm:text-sm"> | |
| 3687 | − Remove this project everywhere | |
| 3688 | − </p> | |
| 3689 | − <p className="text-base text-pretty text-muted-foreground sm:text-sm"> | |
| 3690 | − Deletes all grouped entries and their conversation history. | |
| 3691 | − </p> | |
| 3692 | − </div> | |
| 3693 | − <Button | |
| 3694 | − size="sm" | |
| 3695 | − variant="destructive-outline" | |
| 3696 | − className="shrink-0" | |
| 3697 | − onClick={() => { | |
| 3698 | − const projectGroup = projectActionsTarget; | |
| 3699 | − setProjectActionsTarget(null); | |
| 3700 | − void handleRemoveProjectMembers(projectGroup, projectGroup.memberProjects); | |
| 3701 | − }} | |
| 3702 | − > | |
| 3703 | − <Trash2Icon /> | |
| 3704 | − Remove all entries | |
| 3705 | − </Button> | |
| 3706 | − </div> | |
| 3707 | − ) : null} | |
| 3708 | − </DialogPanel> | |
| 3709 | − <DialogFooter | |
| 3710 | − variant="bare" | |
| 3711 | − className={cn( | |
| 3712 | − projectActionsTarget?.memberProjects.length === 1 && "sm:justify-between", | |
| 3713 | − )} | |
| 3714 | − > | |
| 3715 | − {projectActionsTarget?.memberProjects.length === 1 ? ( | |
| 3716 | − <Button | |
| 3717 | − variant="destructive-outline" | |
| 3718 | − onClick={() => { | |
| 3719 | − const projectGroup = projectActionsTarget; | |
| 3720 | − setProjectActionsTarget(null); | |
| 3721 | − void handleRemoveProjectMembers(projectGroup, projectGroup.memberProjects); | |
| 3722 | − }} | |
| 3723 | − > | |
| 3724 | − <Trash2Icon /> | |
| 3725 | − Remove project | |
| 3726 | − </Button> | |
| 3727 | − ) : null} | |
| 3728 | − <Button onClick={() => setProjectActionsTarget(null)}>Close</Button> | |
| 3729 | − </DialogFooter> | |
| 3730 | − </DialogPopup> | |
| 3731 | − </Dialog> | |
| 3732 | 3378 | <SidebarChromeFooter /> |
| 3733 | 3379 | </> |
| 3734 | 3380 | ); |
| @@ -0,0 +1,415 @@ | ||
| 1 | +import type { | |
| 2 | + ProjectScript, | |
| 3 | + ProjectScriptIcon, | |
| 4 | + ResolvedKeybindingsConfig, | |
| 5 | +} from "@t3tools/contracts"; | |
| 6 | +import { | |
| 7 | + isAtomCommandInterrupted, | |
| 8 | + squashAtomCommandFailure, | |
| 9 | + type AtomCommandResult, | |
| 10 | +} from "@t3tools/client-runtime/state/runtime"; | |
| 11 | +import { | |
| 12 | + BugIcon, | |
| 13 | + FlaskConicalIcon, | |
| 14 | + HammerIcon, | |
| 15 | + ListChecksIcon, | |
| 16 | + PlayIcon, | |
| 17 | + WrenchIcon, | |
| 18 | +} from "lucide-react"; | |
| 19 | +import React, { type FormEvent, type KeyboardEvent, useEffect, useState } from "react"; | |
| 20 | + | |
| 21 | +import { | |
| 22 | + keybindingValueForCommand, | |
| 23 | + decodeProjectScriptKeybindingRule, | |
| 24 | +} from "~/lib/projectScriptKeybindings"; | |
| 25 | +import { keybindingFromKeyboardEvent } from "~/components/settings/KeybindingsSettings.logic"; | |
| 26 | +import { commandForProjectScript, nextProjectScriptId } from "~/projectScripts"; | |
| 27 | +import { | |
| 28 | + AlertDialog, | |
| 29 | + AlertDialogClose, | |
| 30 | + AlertDialogDescription, | |
| 31 | + AlertDialogFooter, | |
| 32 | + AlertDialogHeader, | |
| 33 | + AlertDialogPopup, | |
| 34 | + AlertDialogTitle, | |
| 35 | +} from "./ui/alert-dialog"; | |
| 36 | +import { Button } from "./ui/button"; | |
| 37 | +import { | |
| 38 | + Dialog, | |
| 39 | + DialogDescription, | |
| 40 | + DialogFooter, | |
| 41 | + DialogHeader, | |
| 42 | + DialogPanel, | |
| 43 | + DialogPopup, | |
| 44 | + DialogTitle, | |
| 45 | +} from "./ui/dialog"; | |
| 46 | +import { Input } from "./ui/input"; | |
| 47 | +import { Label } from "./ui/label"; | |
| 48 | +import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; | |
| 49 | +import { Switch } from "./ui/switch"; | |
| 50 | +import { Textarea } from "./ui/textarea"; | |
| 51 | + | |
| 52 | +export const SCRIPT_ICONS: Array<{ id: ProjectScriptIcon; label: string }> = [ | |
| 53 | + { id: "play", label: "Play" }, | |
| 54 | + { id: "test", label: "Test" }, | |
| 55 | + { id: "lint", label: "Lint" }, | |
| 56 | + { id: "configure", label: "Configure" }, | |
| 57 | + { id: "build", label: "Build" }, | |
| 58 | + { id: "debug", label: "Debug" }, | |
| 59 | +]; | |
| 60 | + | |
| 61 | +export function ScriptIcon({ | |
| 62 | + icon, | |
| 63 | + className = "size-3.5", | |
| 64 | +}: { | |
| 65 | + icon: ProjectScriptIcon; | |
| 66 | + className?: string; | |
| 67 | +}) { | |
| 68 | + if (icon === "test") return <FlaskConicalIcon className={className} />; | |
| 69 | + if (icon === "lint") return <ListChecksIcon className={className} />; | |
| 70 | + if (icon === "configure") return <WrenchIcon className={className} />; | |
| 71 | + if (icon === "build") return <HammerIcon className={className} />; | |
| 72 | + if (icon === "debug") return <BugIcon className={className} />; | |
| 73 | + return <PlayIcon className={className} />; | |
| 74 | +} | |
| 75 | + | |
| 76 | +export interface NewProjectScriptInput { | |
| 77 | + name: string; | |
| 78 | + command: string; | |
| 79 | + icon: ProjectScriptIcon; | |
| 80 | + runOnWorktreeCreate: boolean; | |
| 81 | + keybinding: string | null; | |
| 82 | + /** Optional URL to open in the in-app preview when this script runs. */ | |
| 83 | + previewUrl: string | null; | |
| 84 | + /** When true, automatically open the preview panel pointed at `previewUrl`. */ | |
| 85 | + autoOpenPreview: boolean; | |
| 86 | +} | |
| 87 | + | |
| 88 | +export type ProjectScriptActionResult = AtomCommandResult<void, unknown>; | |
| 89 | + | |
| 90 | +export const EMPTY_PROJECT_SCRIPT_INPUT: NewProjectScriptInput = { | |
| 91 | + name: "", | |
| 92 | + command: "", | |
| 93 | + icon: "play", | |
| 94 | + runOnWorktreeCreate: false, | |
| 95 | + keybinding: null, | |
| 96 | + previewUrl: null, | |
| 97 | + autoOpenPreview: false, | |
| 98 | +}; | |
| 99 | + | |
| 100 | +/** What the editor dialog should open with. `scriptId: null` means "add". */ | |
| 101 | +export interface ProjectScriptEditorRequest { | |
| 102 | + scriptId: string | null; | |
| 103 | + initial: NewProjectScriptInput; | |
| 104 | + /** Validation error to show immediately (e.g. a failed t3.json import). */ | |
| 105 | + error?: string; | |
| 106 | +} | |
| 107 | + | |
| 108 | +export function editorRequestForScript( | |
| 109 | + script: ProjectScript, | |
| 110 | + keybindings: ResolvedKeybindingsConfig, | |
| 111 | +): ProjectScriptEditorRequest { | |
| 112 | + return { | |
| 113 | + scriptId: script.id, | |
| 114 | + initial: { | |
| 115 | + name: script.name, | |
| 116 | + command: script.command, | |
| 117 | + icon: script.icon, | |
| 118 | + runOnWorktreeCreate: script.runOnWorktreeCreate, | |
| 119 | + keybinding: keybindingValueForCommand(keybindings, commandForProjectScript(script.id)), | |
| 120 | + previewUrl: script.previewUrl ?? null, | |
| 121 | + autoOpenPreview: script.autoOpenPreview ?? false, | |
| 122 | + }, | |
| 123 | + }; | |
| 124 | +} | |
| 125 | + | |
| 126 | +/** | |
| 127 | + * Add/edit dialog for a project script, shared by the chat-header scripts menu | |
| 128 | + * and the project settings page. The parent owns which script (if any) is | |
| 129 | + * being edited via `request`; the dialog owns the form state and validation. | |
| 130 | + */ | |
| 131 | +export function ProjectScriptEditorDialog({ | |
| 132 | + request, | |
| 133 | + scripts, | |
| 134 | + onSubmit, | |
| 135 | + onDelete, | |
| 136 | + onClose, | |
| 137 | +}: { | |
| 138 | + request: ProjectScriptEditorRequest | null; | |
| 139 | + /** Existing scripts, used to derive a unique id for new scripts. */ | |
| 140 | + scripts: ReadonlyArray<ProjectScript>; | |
| 141 | + onSubmit: ( | |
| 142 | + scriptId: string | null, | |
| 143 | + input: NewProjectScriptInput, | |
| 144 | + ) => Promise<ProjectScriptActionResult>; | |
| 145 | + onDelete: (scriptId: string) => void; | |
| 146 | + onClose: () => void; | |
| 147 | +}) { | |
| 148 | + const formId = React.useId(); | |
| 149 | + const [name, setName] = useState(""); | |
| 150 | + const [command, setCommand] = useState(""); | |
| 151 | + const [icon, setIcon] = useState<ProjectScriptIcon>("play"); | |
| 152 | + const [iconPickerOpen, setIconPickerOpen] = useState(false); | |
| 153 | + const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false); | |
| 154 | + const [keybinding, setKeybinding] = useState(""); | |
| 155 | + const [previewUrl, setPreviewUrl] = useState(""); | |
| 156 | + const [autoOpenPreview, setAutoOpenPreview] = useState(false); | |
| 157 | + const [validationError, setValidationError] = useState<string | null>(null); | |
| 158 | + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); | |
| 159 | + | |
| 160 | + const isOpen = request !== null; | |
| 161 | + const isEditing = request?.scriptId != null; | |
| 162 | + | |
| 163 | + // Hydrate the form whenever a new request opens the dialog. | |
| 164 | + useEffect(() => { | |
| 165 | + if (!request) return; | |
| 166 | + setName(request.initial.name); | |
| 167 | + setCommand(request.initial.command); | |
| 168 | + setIcon(request.initial.icon); | |
| 169 | + setIconPickerOpen(false); | |
| 170 | + setRunOnWorktreeCreate(request.initial.runOnWorktreeCreate); | |
| 171 | + setKeybinding(request.initial.keybinding ?? ""); | |
| 172 | + setPreviewUrl(request.initial.previewUrl ?? ""); | |
| 173 | + setAutoOpenPreview(request.initial.autoOpenPreview); | |
| 174 | + setValidationError(request.error ?? null); | |
| 175 | + }, [request]); | |
| 176 | + | |
| 177 | + const captureKeybinding = (event: KeyboardEvent<HTMLInputElement>) => { | |
| 178 | + if (event.key === "Tab") return; | |
| 179 | + event.preventDefault(); | |
| 180 | + if (event.key === "Backspace" || event.key === "Delete") { | |
| 181 | + setKeybinding(""); | |
| 182 | + return; | |
| 183 | + } | |
| 184 | + const next = keybindingFromKeyboardEvent(event, navigator.platform); | |
| 185 | + if (!next) return; | |
| 186 | + setKeybinding(next); | |
| 187 | + }; | |
| 188 | + | |
| 189 | + const submit = async (event: FormEvent) => { | |
| 190 | + event.preventDefault(); | |
| 191 | + if (!request) return; | |
| 192 | + const trimmedName = name.trim(); | |
| 193 | + const trimmedCommand = command.trim(); | |
| 194 | + if (trimmedName.length === 0) { | |
| 195 | + setValidationError("Name is required."); | |
| 196 | + return; | |
| 197 | + } | |
| 198 | + if (trimmedCommand.length === 0) { | |
| 199 | + setValidationError("Command is required."); | |
| 200 | + return; | |
| 201 | + } | |
| 202 | + | |
| 203 | + setValidationError(null); | |
| 204 | + let payload: NewProjectScriptInput; | |
| 205 | + try { | |
| 206 | + const scriptIdForValidation = | |
| 207 | + request.scriptId ?? | |
| 208 | + nextProjectScriptId( | |
| 209 | + trimmedName, | |
| 210 | + scripts.map((script) => script.id), | |
| 211 | + ); | |
| 212 | + const keybindingRule = decodeProjectScriptKeybindingRule({ | |
| 213 | + keybinding, | |
| 214 | + command: commandForProjectScript(scriptIdForValidation), | |
| 215 | + }); | |
| 216 | + const trimmedPreviewUrl = previewUrl.trim(); | |
| 217 | + payload = { | |
| 218 | + name: trimmedName, | |
| 219 | + command: trimmedCommand, | |
| 220 | + icon, | |
| 221 | + runOnWorktreeCreate, | |
| 222 | + keybinding: keybindingRule?.key ?? null, | |
| 223 | + previewUrl: trimmedPreviewUrl.length > 0 ? trimmedPreviewUrl : null, | |
| 224 | + autoOpenPreview: trimmedPreviewUrl.length > 0 ? autoOpenPreview : false, | |
| 225 | + } satisfies NewProjectScriptInput; | |
| 226 | + } catch (error) { | |
| 227 | + setValidationError(error instanceof Error ? error.message : "Failed to save action."); | |
| 228 | + return; | |
| 229 | + } | |
| 230 | + | |
| 231 | + const result = await onSubmit(request.scriptId, payload); | |
| 232 | + if (result._tag === "Failure") { | |
| 233 | + if (!isAtomCommandInterrupted(result)) { | |
| 234 | + const error = squashAtomCommandFailure(result); | |
| 235 | + setValidationError(error instanceof Error ? error.message : "Failed to save action."); | |
| 236 | + } | |
| 237 | + return; | |
| 238 | + } | |
| 239 | + setIconPickerOpen(false); | |
| 240 | + onClose(); | |
| 241 | + }; | |
| 242 | + | |
| 243 | + return ( | |
| 244 | + <> | |
| 245 | + <Dialog | |
| 246 | + open={isOpen} | |
| 247 | + onOpenChange={(open) => { | |
| 248 | + if (!open) { | |
| 249 | + setIconPickerOpen(false); | |
| 250 | + onClose(); | |
| 251 | + } | |
| 252 | + }} | |
| 253 | + > | |
| 254 | + <DialogPopup> | |
| 255 | + <DialogHeader> | |
| 256 | + <DialogTitle>{isEditing ? "Edit Action" : "Add Action"}</DialogTitle> | |
| 257 | + <DialogDescription> | |
| 258 | + Actions are project-scoped commands you can run from the top bar or keybindings. | |
| 259 | + </DialogDescription> | |
| 260 | + </DialogHeader> | |
| 261 | + <DialogPanel> | |
| 262 | + <form id={formId} className="space-y-4" onSubmit={submit}> | |
| 263 | + <div className="space-y-1.5"> | |
| 264 | + <Label htmlFor="script-name">Name</Label> | |
| 265 | + <div className="flex items-center gap-2"> | |
| 266 | + <Popover onOpenChange={setIconPickerOpen} open={iconPickerOpen}> | |
| 267 | + <PopoverTrigger | |
| 268 | + render={ | |
| 269 | + <Button | |
| 270 | + type="button" | |
| 271 | + variant="outline" | |
| 272 | + 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" | |
| 273 | + aria-label="Choose icon" | |
| 274 | + /> | |
| 275 | + } | |
| 276 | + > | |
| 277 | + <ScriptIcon icon={icon} className="size-4.5" /> | |
| 278 | + </PopoverTrigger> | |
| 279 | + <PopoverPopup align="start"> | |
| 280 | + <div className="grid grid-cols-3 gap-2"> | |
| 281 | + {SCRIPT_ICONS.map((entry) => { | |
| 282 | + const isSelected = entry.id === icon; | |
| 283 | + return ( | |
| 284 | + <button | |
| 285 | + key={entry.id} | |
| 286 | + type="button" | |
| 287 | + className={`relative flex flex-col items-center gap-2 rounded-md border px-2 py-2 text-xs dark:border-transparent ${ | |
| 288 | + isSelected | |
| 289 | + ? "border-primary/70 bg-primary/10 dark:ring-1 dark:ring-primary/30" | |
| 290 | + : "border-border/70 hover:bg-accent/60 dark:bg-white/[0.035]" | |
| 291 | + }`} | |
| 292 | + onClick={() => { | |
| 293 | + setIcon(entry.id); | |
| 294 | + setIconPickerOpen(false); | |
| 295 | + }} | |
| 296 | + > | |
| 297 | + <ScriptIcon icon={entry.id} className="size-4" /> | |
| 298 | + <span>{entry.label}</span> | |
| 299 | + </button> | |
| 300 | + ); | |
| 301 | + })} | |
| 302 | + </div> | |
| 303 | + </PopoverPopup> | |
| 304 | + </Popover> | |
| 305 | + <Input | |
| 306 | + id="script-name" | |
| 307 | + autoFocus | |
| 308 | + placeholder="Test" | |
| 309 | + value={name} | |
| 310 | + onChange={(event) => setName(event.target.value)} | |
| 311 | + /> | |
| 312 | + </div> | |
| 313 | + </div> | |
| 314 | + <div className="space-y-1.5"> | |
| 315 | + <Label htmlFor="script-keybinding">Keybinding</Label> | |
| 316 | + <Input | |
| 317 | + id="script-keybinding" | |
| 318 | + placeholder="Press shortcut" | |
| 319 | + value={keybinding} | |
| 320 | + readOnly | |
| 321 | + onKeyDown={captureKeybinding} | |
| 322 | + /> | |
| 323 | + <p className="text-xs text-muted-foreground"> | |
| 324 | + Press a shortcut. Use <code>Backspace</code> to clear. | |
| 325 | + </p> | |
| 326 | + </div> | |
| 327 | + <div className="space-y-1.5"> | |
| 328 | + <Label htmlFor="script-command">Command</Label> | |
| 329 | + <Textarea | |
| 330 | + id="script-command" | |
| 331 | + placeholder="bun test" | |
| 332 | + value={command} | |
| 333 | + onChange={(event) => setCommand(event.target.value)} | |
| 334 | + /> | |
| 335 | + </div> | |
| 336 | + <div className="space-y-1.5"> | |
| 337 | + <Label htmlFor="script-preview-url">Preview URL (optional)</Label> | |
| 338 | + <Input | |
| 339 | + id="script-preview-url" | |
| 340 | + placeholder="http://localhost:5173" | |
| 341 | + value={previewUrl} | |
| 342 | + onChange={(event) => setPreviewUrl(event.target.value)} | |
| 343 | + /> | |
| 344 | + <p className="text-xs text-muted-foreground"> | |
| 345 | + Open this URL in the in-app preview when this action runs. | |
| 346 | + </p> | |
| 347 | + </div> | |
| 348 | + <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]"> | |
| 349 | + <span>Run automatically on worktree creation</span> | |
| 350 | + <Switch | |
| 351 | + checked={runOnWorktreeCreate} | |
| 352 | + onCheckedChange={(checked) => setRunOnWorktreeCreate(Boolean(checked))} | |
| 353 | + /> | |
| 354 | + </label> | |
| 355 | + <label | |
| 356 | + 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] ${ | |
| 357 | + previewUrl.trim().length === 0 ? "opacity-60" : "" | |
| 358 | + }`} | |
| 359 | + > | |
| 360 | + <span>Open preview automatically when this action runs</span> | |
| 361 | + <Switch | |
| 362 | + checked={autoOpenPreview} | |
| 363 | + disabled={previewUrl.trim().length === 0} | |
| 364 | + onCheckedChange={(checked) => setAutoOpenPreview(Boolean(checked))} | |
| 365 | + /> | |
| 366 | + </label> | |
| 367 | + {validationError && <p className="text-sm text-destructive">{validationError}</p>} | |
| 368 | + </form> | |
| 369 | + </DialogPanel> | |
| 370 | + <DialogFooter className="dark:border-transparent dark:bg-transparent"> | |
| 371 | + {isEditing && ( | |
| 372 | + <Button | |
| 373 | + type="button" | |
| 374 | + variant="destructive-outline" | |
| 375 | + className="mr-auto" | |
| 376 | + onClick={() => setDeleteConfirmOpen(true)} | |
| 377 | + > | |
| 378 | + Delete | |
| 379 | + </Button> | |
| 380 | + )} | |
| 381 | + <Button type="button" variant="outline" onClick={onClose}> | |
| 382 | + Cancel | |
| 383 | + </Button> | |
| 384 | + <Button form={formId} type="submit"> | |
| 385 | + {isEditing ? "Save changes" : "Save action"} | |
| 386 | + </Button> | |
| 387 | + </DialogFooter> | |
| 388 | + </DialogPopup> | |
| 389 | + </Dialog> | |
| 390 | + | |
| 391 | + <AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}> | |
| 392 | + <AlertDialogPopup> | |
| 393 | + <AlertDialogHeader> | |
| 394 | + <AlertDialogTitle>Delete action "{name}"?</AlertDialogTitle> | |
| 395 | + <AlertDialogDescription>This action cannot be undone.</AlertDialogDescription> | |
| 396 | + </AlertDialogHeader> | |
| 397 | + <AlertDialogFooter> | |
| 398 | + <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose> | |
| 399 | + <Button | |
| 400 | + variant="destructive" | |
| 401 | + onClick={() => { | |
| 402 | + if (!request?.scriptId) return; | |
| 403 | + setDeleteConfirmOpen(false); | |
| 404 | + onClose(); | |
| 405 | + onDelete(request.scriptId); | |
| 406 | + }} | |
| 407 | + > | |
| 408 | + Delete action | |
| 409 | + </Button> | |
| 410 | + </AlertDialogFooter> | |
| 411 | + </AlertDialogPopup> | |
| 412 | + </AlertDialog> | |
| 413 | + </> | |
| 414 | + ); | |
| 415 | +} | |
| @@ -0,0 +1,1011 @@ | ||
| 1 | +import { useAtomValue } from "@effect/atom-react"; | |
| 2 | +import { | |
| 3 | + isAtomCommandInterrupted, | |
| 4 | + mapAtomCommandResult, | |
| 5 | + settlePromise, | |
| 6 | + squashAtomCommandFailure, | |
| 7 | + type AtomCommandResult, | |
| 8 | +} from "@t3tools/client-runtime/state/runtime"; | |
| 9 | +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; | |
| 10 | +import { AsyncResult } from "effect/unstable/reactivity"; | |
| 11 | +import { | |
| 12 | + deriveProjectGroupingOverrideKey, | |
| 13 | + selectProjectGroupingSettings, | |
| 14 | +} from "../../logicalProject"; | |
| 15 | +import type { | |
| 16 | + ModelSelection, | |
| 17 | + ProviderDriverKind, | |
| 18 | + SidebarProjectGroupingMode, | |
| 19 | + T3ProjectFileScript, | |
| 20 | +} from "@t3tools/contracts"; | |
| 21 | +import { createModelSelection } from "@t3tools/shared/model"; | |
| 22 | +import { useLocation, useNavigate } from "@tanstack/react-router"; | |
| 23 | +import * as Cause from "effect/Cause"; | |
| 24 | +import { CopyIcon, FolderIcon, PlusIcon, ServerIcon, SettingsIcon, Trash2Icon } from "lucide-react"; | |
| 25 | +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 26 | + | |
| 27 | +import { useComposerDraftStore } from "../../composerDraftStore"; | |
| 28 | +import { isElectron } from "../../env"; | |
| 29 | +import { | |
| 30 | + useClientSettings, | |
| 31 | + useUpdateClientSettings, | |
| 32 | + usePrimarySettings, | |
| 33 | +} from "../../hooks/useSettings"; | |
| 34 | +import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; | |
| 35 | +import { useT3ProjectFileState } from "../../hooks/useT3ProjectFileScripts"; | |
| 36 | +import { shortcutLabelForCommand } from "../../keybindings"; | |
| 37 | +import { keybindingValueForCommand } from "../../lib/projectScriptKeybindings"; | |
| 38 | +import { readLocalApi } from "../../localApi"; | |
| 39 | +import { | |
| 40 | + buildProjectScript, | |
| 41 | + commandForProjectScript, | |
| 42 | + nextProjectScriptId, | |
| 43 | +} from "../../projectScripts"; | |
| 44 | +import { decodeProjectScriptKeybindingRule } from "../../lib/projectScriptKeybindings"; | |
| 45 | +import { | |
| 46 | + applyProviderInstanceSettings, | |
| 47 | + deriveProviderInstanceEntries, | |
| 48 | + resolveDefaultProviderModelSelection, | |
| 49 | + sortProviderInstanceEntries, | |
| 50 | +} from "../../providerInstances"; | |
| 51 | +import { getCustomModelOptionsByInstance } from "../../modelSelection"; | |
| 52 | +import { | |
| 53 | + buildSidebarProjectSnapshots, | |
| 54 | + type SidebarProjectGroupMember, | |
| 55 | + type SidebarProjectSnapshot, | |
| 56 | +} from "../../sidebarProjectGrouping"; | |
| 57 | +import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments"; | |
| 58 | +import { useProjects, useThreadShells } from "../../state/entities"; | |
| 59 | +import { projectEnvironment } from "../../state/projects"; | |
| 60 | +import { | |
| 61 | + primaryServerKeybindingsAtom, | |
| 62 | + primaryServerProvidersAtom, | |
| 63 | + serverEnvironment, | |
| 64 | +} from "../../state/server"; | |
| 65 | +import { useAtomCommand } from "../../state/use-atom-command"; | |
| 66 | +import { ProviderModelPicker } from "../chat/ProviderModelPicker"; | |
| 67 | +import { TraitsPicker } from "../chat/TraitsPicker"; | |
| 68 | +import { ProjectFavicon } from "../ProjectFavicon"; | |
| 69 | +import { | |
| 70 | + EMPTY_PROJECT_SCRIPT_INPUT, | |
| 71 | + editorRequestForScript, | |
| 72 | + ProjectScriptEditorDialog, | |
| 73 | + ScriptIcon, | |
| 74 | + type NewProjectScriptInput, | |
| 75 | + type ProjectScriptEditorRequest, | |
| 76 | +} from "../projectScriptEditor"; | |
| 77 | +import { Button } from "../ui/button"; | |
| 78 | +import { Input } from "../ui/input"; | |
| 79 | +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; | |
| 80 | +import { stackedThreadToast, toastManager } from "../ui/toast"; | |
| 81 | +import { | |
| 82 | + SettingResetButton, | |
| 83 | + SettingsPageContainer, | |
| 84 | + SettingsRow, | |
| 85 | + SettingsSection, | |
| 86 | +} from "./settingsLayout"; | |
| 87 | + | |
| 88 | +export const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = { | |
| 89 | + repository: "Group by repository", | |
| 90 | + repository_path: "Group by repository path", | |
| 91 | + separate: "Keep separate", | |
| 92 | +}; | |
| 93 | + | |
| 94 | +/** Logical project groups for the settings page, sorted by display name. */ | |
| 95 | +export function useSettingsProjectGroups(): SidebarProjectSnapshot[] { | |
| 96 | + const projects = useProjects(); | |
| 97 | + const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); | |
| 98 | + const primaryEnvironmentId = usePrimaryEnvironmentId(); | |
| 99 | + const { environments } = useEnvironments(); | |
| 100 | + const environmentLabelById = useMemo( | |
| 101 | + () => | |
| 102 | + new Map( | |
| 103 | + environments.map((environment) => [environment.environmentId, environment.label] as const), | |
| 104 | + ), | |
| 105 | + [environments], | |
| 106 | + ); | |
| 107 | + return useMemo( | |
| 108 | + () => | |
| 109 | + buildSidebarProjectSnapshots({ | |
| 110 | + projects, | |
| 111 | + settings: projectGroupingSettings, | |
| 112 | + primaryEnvironmentId, | |
| 113 | + resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, | |
| 114 | + }).sort((a, b) => a.displayName.localeCompare(b.displayName)), | |
| 115 | + [environmentLabelById, primaryEnvironmentId, projectGroupingSettings, projects], | |
| 116 | + ); | |
| 117 | +} | |
| 118 | + | |
| 119 | +function memberKey(member: { environmentId: string; id: string }): string { | |
| 120 | + return `${member.environmentId}:${member.id}`; | |
| 121 | +} | |
| 122 | + | |
| 123 | +export function ProjectSettingsPanel({ | |
| 124 | + selectedProjectKey, | |
| 125 | +}: { | |
| 126 | + selectedProjectKey: string | null; | |
| 127 | +}) { | |
| 128 | + const groups = useSettingsProjectGroups(); | |
| 129 | + const navigate = useNavigate(); | |
| 130 | + const currentHash = useLocation({ select: (location) => location.hash }); | |
| 131 | + | |
| 132 | + // The index route auto-selects the first project so /settings/projects is | |
| 133 | + // never a dead end. Hash is preserved for settings-search jumps. | |
| 134 | + useEffect(() => { | |
| 135 | + if (selectedProjectKey !== null) return; | |
| 136 | + const first = groups[0]; | |
| 137 | + if (!first) return; | |
| 138 | + void navigate({ | |
| 139 | + to: "/settings/projects/$projectKey", | |
| 140 | + params: { projectKey: first.projectKey }, | |
| 141 | + ...(currentHash ? { hash: currentHash } : {}), | |
| 142 | + replace: true, | |
| 143 | + hashScrollIntoView: false, | |
| 144 | + }); | |
| 145 | + }, [currentHash, groups, navigate, selectedProjectKey]); | |
| 146 | + | |
| 147 | + const selected = | |
| 148 | + selectedProjectKey === null | |
| 149 | + ? null | |
| 150 | + : (groups.find((group) => group.projectKey === selectedProjectKey) ?? null); | |
| 151 | + | |
| 152 | + // Remember the members of the last rendered group so a grouping-rule change | |
| 153 | + // (which changes the group key) can follow the project to its new group. | |
| 154 | + const lastSelectionRef = useRef<{ key: string; memberKeys: string[] } | null>(null); | |
| 155 | + useEffect(() => { | |
| 156 | + if (!selected) return; | |
| 157 | + lastSelectionRef.current = { | |
| 158 | + key: selected.projectKey, | |
| 159 | + memberKeys: selected.memberProjects.map((member) => member.physicalProjectKey), | |
| 160 | + }; | |
| 161 | + }, [selected]); | |
| 162 | + | |
| 163 | + // Recover when the selected key stops matching (regroup, removal, or a | |
| 164 | + // stale deep link) instead of parking on a dead-end message. | |
| 165 | + useEffect(() => { | |
| 166 | + if (selectedProjectKey === null || selected !== null || groups.length === 0) return; | |
| 167 | + const last = lastSelectionRef.current; | |
| 168 | + const successor = | |
| 169 | + last?.key === selectedProjectKey | |
| 170 | + ? (groups.find((group) => | |
| 171 | + group.memberProjects.some((member) => | |
| 172 | + last.memberKeys.includes(member.physicalProjectKey), | |
| 173 | + ), | |
| 174 | + ) ?? null) | |
| 175 | + : null; | |
| 176 | + if (successor) { | |
| 177 | + void navigate({ | |
| 178 | + to: "/settings/projects/$projectKey", | |
| 179 | + params: { projectKey: successor.projectKey }, | |
| 180 | + replace: true, | |
| 181 | + hashScrollIntoView: false, | |
| 182 | + }); | |
| 183 | + } else { | |
| 184 | + void navigate({ to: "/settings/projects", replace: true, hashScrollIntoView: false }); | |
| 185 | + } | |
| 186 | + }, [groups, navigate, selected, selectedProjectKey]); | |
| 187 | + | |
| 188 | + const selectProject = useCallback( | |
| 189 | + (projectKey: string) => { | |
| 190 | + void navigate({ | |
| 191 | + to: "/settings/projects/$projectKey", | |
| 192 | + params: { projectKey }, | |
| 193 | + replace: true, | |
| 194 | + hashScrollIntoView: false, | |
| 195 | + }); | |
| 196 | + }, | |
| 197 | + [navigate], | |
| 198 | + ); | |
| 199 | + | |
| 200 | + return ( | |
| 201 | + <div className="flex min-h-0 flex-1"> | |
| 202 | + <nav | |
| 203 | + aria-label="Projects" | |
| 204 | + className="w-60 shrink-0 space-y-0.5 overflow-y-auto border-e border-border/40 p-3 pt-10 max-sm:hidden sm:pt-12" | |
| 205 | + > | |
| 206 | + {groups.map((group) => { | |
| 207 | + const isActive = group.projectKey === selected?.projectKey; | |
| 208 | + return ( | |
| 209 | + <button | |
| 210 | + key={group.projectKey} | |
| 211 | + type="button" | |
| 212 | + onClick={() => selectProject(group.projectKey)} | |
| 213 | + className={`flex w-full min-w-0 items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring ${ | |
| 214 | + isActive | |
| 215 | + ? "bg-accent text-foreground" | |
| 216 | + : "text-muted-foreground hover:bg-accent/60 hover:text-foreground" | |
| 217 | + }`} | |
| 218 | + > | |
| 219 | + <ProjectFavicon | |
| 220 | + environmentId={group.environmentId} | |
| 221 | + cwd={group.workspaceRoot} | |
| 222 | + className="size-4 shrink-0" | |
| 223 | + /> | |
| 224 | + <span className="min-w-0 flex-1 truncate">{group.displayName}</span> | |
| 225 | + {group.groupedProjectCount > 1 ? ( | |
| 226 | + <span className="shrink-0 text-xs text-muted-foreground/70"> | |
| 227 | + {group.groupedProjectCount} | |
| 228 | + </span> | |
| 229 | + ) : null} | |
| 230 | + </button> | |
| 231 | + ); | |
| 232 | + })} | |
| 233 | + {groups.length === 0 ? ( | |
| 234 | + <p className="px-2 py-4 text-sm text-muted-foreground">No projects yet.</p> | |
| 235 | + ) : null} | |
| 236 | + </nav> | |
| 237 | + {selected ? ( | |
| 238 | + <ProjectDetail | |
| 239 | + key={selected.projectKey} | |
| 240 | + group={selected} | |
| 241 | + groups={groups} | |
| 242 | + onSelectProject={selectProject} | |
| 243 | + /> | |
| 244 | + ) : ( | |
| 245 | + <div className="flex flex-1 items-center justify-center p-8 text-sm text-muted-foreground"> | |
| 246 | + {groups.length === 0 ? "Add a project from the sidebar to configure it here." : null} | |
| 247 | + </div> | |
| 248 | + )} | |
| 249 | + </div> | |
| 250 | + ); | |
| 251 | +} | |
| 252 | + | |
| 253 | +function ProjectDetail({ | |
| 254 | + group, | |
| 255 | + groups, | |
| 256 | + onSelectProject, | |
| 257 | +}: { | |
| 258 | + group: SidebarProjectSnapshot; | |
| 259 | + groups: ReadonlyArray<SidebarProjectSnapshot>; | |
| 260 | + onSelectProject: (projectKey: string) => void; | |
| 261 | +}) { | |
| 262 | + const navigate = useNavigate(); | |
| 263 | + const settings = usePrimarySettings(); | |
| 264 | + const updateClientSettings = useUpdateClientSettings(); | |
| 265 | + const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); | |
| 266 | + const serverProviders = useAtomValue(primaryServerProvidersAtom); | |
| 267 | + const keybindings = useAtomValue(primaryServerKeybindingsAtom); | |
| 268 | + const threads = useThreadShells(); | |
| 269 | + const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false }); | |
| 270 | + const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false }); | |
| 271 | + const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, { | |
| 272 | + reportFailure: false, | |
| 273 | + }); | |
| 274 | + const removeKeybinding = useAtomCommand(serverEnvironment.removeKeybinding, { | |
| 275 | + reportFailure: false, | |
| 276 | + }); | |
| 277 | + const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({ | |
| 278 | + onCopy: ({ path }) => { | |
| 279 | + toastManager.add({ type: "success", title: "Path copied", description: path }); | |
| 280 | + }, | |
| 281 | + onError: (error) => { | |
| 282 | + toastManager.add( | |
| 283 | + stackedThreadToast({ | |
| 284 | + type: "error", | |
| 285 | + title: "Failed to copy path", | |
| 286 | + description: error instanceof Error ? error.message : "An error occurred.", | |
| 287 | + }), | |
| 288 | + ); | |
| 289 | + }, | |
| 290 | + }); | |
| 291 | + | |
| 292 | + const representative = | |
| 293 | + group.memberProjects.find( | |
| 294 | + (member) => member.environmentId === group.environmentId && member.id === group.id, | |
| 295 | + ) ?? group.memberProjects[0]!; | |
| 296 | + | |
| 297 | + const threadCountByMember = useMemo(() => { | |
| 298 | + const counts = new Map<string, number>(); | |
| 299 | + for (const thread of threads) { | |
| 300 | + const key = `${thread.environmentId}:${thread.projectId}`; | |
| 301 | + counts.set(key, (counts.get(key) ?? 0) + 1); | |
| 302 | + } | |
| 303 | + return counts; | |
| 304 | + }, [threads]); | |
| 305 | + const groupThreadCount = group.memberProjects.reduce( | |
| 306 | + (total, member) => total + (threadCountByMember.get(memberKey(member)) ?? 0), | |
| 307 | + 0, | |
| 308 | + ); | |
| 309 | + | |
| 310 | + const reportFailure = useCallback((title: string, result: AtomCommandResult<void, unknown>) => { | |
| 311 | + if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; | |
| 312 | + const error = squashAtomCommandFailure(result); | |
| 313 | + toastManager.add( | |
| 314 | + stackedThreadToast({ | |
| 315 | + type: "error", | |
| 316 | + title, | |
| 317 | + description: error instanceof Error ? error.message : "An error occurred.", | |
| 318 | + }), | |
| 319 | + ); | |
| 320 | + }, []); | |
| 321 | + | |
| 322 | + // Group-shared fields (default model, scripts) live on each physical | |
| 323 | + // project record, so a group-level edit fans out to every member. | |
| 324 | + const updateAllMembers = useCallback( | |
| 325 | + async ( | |
| 326 | + input: Partial<{ | |
| 327 | + defaultModelSelection: ModelSelection | null; | |
| 328 | + scripts: ReadonlyArray<ReturnType<typeof buildProjectScript>>; | |
| 329 | + }>, | |
| 330 | + failureTitle: string, | |
| 331 | + ): Promise<AtomCommandResult<void, unknown>> => { | |
| 332 | + for (const member of group.memberProjects) { | |
| 333 | + const result = mapAtomCommandResult( | |
| 334 | + await updateProject({ | |
| 335 | + environmentId: member.environmentId, | |
| 336 | + input: { projectId: member.id, ...input }, | |
| 337 | + }), | |
| 338 | + () => undefined, | |
| 339 | + ); | |
| 340 | + if (result._tag === "Failure") { | |
| 341 | + // A partial fan-out is possible: earlier members already took the | |
| 342 | + // write. Name the environment so the user knows where it stopped. | |
| 343 | + reportFailure( | |
| 344 | + group.memberProjects.length > 1 | |
| 345 | + ? `${failureTitle} on ${member.environmentLabel ?? "the current environment"}` | |
| 346 | + : failureTitle, | |
| 347 | + result, | |
| 348 | + ); | |
| 349 | + return result; | |
| 350 | + } | |
| 351 | + } | |
| 352 | + return AsyncResult.success(undefined); | |
| 353 | + }, | |
| 354 | + [group.memberProjects, reportFailure, updateProject], | |
| 355 | + ); | |
| 356 | + | |
| 357 | + // ----- default model ----- | |
| 358 | + const storedSelection = representative.defaultModelSelection; | |
| 359 | + const resolvedSelection = resolveDefaultProviderModelSelection(serverProviders, storedSelection); | |
| 360 | + const instanceEntries = useMemo( | |
| 361 | + () => | |
| 362 | + sortProviderInstanceEntries( | |
| 363 | + applyProviderInstanceSettings(deriveProviderInstanceEntries(serverProviders), settings), | |
| 364 | + ), | |
| 365 | + [serverProviders, settings], | |
| 366 | + ); | |
| 367 | + const modelOptionsByInstance = useMemo( | |
| 368 | + () => getCustomModelOptionsByInstance(settings, serverProviders), | |
| 369 | + [serverProviders, settings], | |
| 370 | + ); | |
| 371 | + const activeEntry = instanceEntries.find( | |
| 372 | + (entry) => entry.instanceId === resolvedSelection?.instanceId, | |
| 373 | + ); | |
| 374 | + const setDefaultModel = useCallback( | |
| 375 | + (selection: ModelSelection | null) => | |
| 376 | + void updateAllMembers({ defaultModelSelection: selection }, "Failed to update default model"), | |
| 377 | + [updateAllMembers], | |
| 378 | + ); | |
| 379 | + | |
| 380 | + // ----- scripts ----- | |
| 381 | + const scripts = representative.scripts; | |
| 382 | + const [editorRequest, setEditorRequest] = useState<ProjectScriptEditorRequest | null>(null); | |
| 383 | + // Script writes replace the whole array, so two overlapping writes computed | |
| 384 | + // from the same snapshot would drop each other's changes. One at a time. | |
| 385 | + const [isSavingScripts, setIsSavingScripts] = useState(false); | |
| 386 | + const savingScriptsRef = useRef(false); | |
| 387 | + const t3File = useT3ProjectFileState(representative.environmentId, representative.workspaceRoot); | |
| 388 | + const importableScripts = useMemo( | |
| 389 | + () => | |
| 390 | + t3File.scripts.filter( | |
| 391 | + (fileScript) => | |
| 392 | + !scripts.some( | |
| 393 | + (script) => | |
| 394 | + script.command === fileScript.command || | |
| 395 | + script.name.toLowerCase() === fileScript.name.toLowerCase(), | |
| 396 | + ), | |
| 397 | + ), | |
| 398 | + [scripts, t3File.scripts], | |
| 399 | + ); | |
| 400 | + | |
| 401 | + const persistScripts = useCallback( | |
| 402 | + async ( | |
| 403 | + nextScripts: ReadonlyArray<ReturnType<typeof buildProjectScript>>, | |
| 404 | + keybinding: string | null | undefined, | |
| 405 | + keybindingCommand: ReturnType<typeof commandForProjectScript>, | |
| 406 | + ): Promise<AtomCommandResult<void, unknown>> => { | |
| 407 | + if (savingScriptsRef.current) { | |
| 408 | + return AsyncResult.failure( | |
| 409 | + Cause.fail(new Error("Another script change is still saving. Try again.")), | |
| 410 | + ); | |
| 411 | + } | |
| 412 | + savingScriptsRef.current = true; | |
| 413 | + setIsSavingScripts(true); | |
| 414 | + try { | |
| 415 | + // Captured before the write so a cleared or deleted binding can be | |
| 416 | + // removed from the keybindings config afterwards. | |
| 417 | + const previousKeybinding = keybindingValueForCommand(keybindings, keybindingCommand); | |
| 418 | + const updateResult = await updateAllMembers( | |
| 419 | + { scripts: nextScripts }, | |
| 420 | + "Failed to save scripts", | |
| 421 | + ); | |
| 422 | + if (updateResult._tag === "Failure") return updateResult; | |
| 423 | + | |
| 424 | + const keybindingRule = decodeProjectScriptKeybindingRule({ | |
| 425 | + keybinding, | |
| 426 | + command: keybindingCommand, | |
| 427 | + }); | |
| 428 | + if (!isElectron) return updateResult; | |
| 429 | + const environmentIds = [ | |
| 430 | + ...new Set(group.memberProjects.map((member) => member.environmentId)), | |
| 431 | + ]; | |
| 432 | + const previousTarget = previousKeybinding | |
| 433 | + ? decodeProjectScriptKeybindingRule({ | |
| 434 | + keybinding: previousKeybinding, | |
| 435 | + command: keybindingCommand, | |
| 436 | + }) | |
| 437 | + : null; | |
| 438 | + if (keybindingRule) { | |
| 439 | + // `replace` swaps the command's previous rule instead of appending a | |
| 440 | + // second one that would keep the old shortcut alive. | |
| 441 | + const input = | |
| 442 | + previousTarget && previousTarget.key !== keybindingRule.key | |
| 443 | + ? { ...keybindingRule, replace: previousTarget } | |
| 444 | + : keybindingRule; | |
| 445 | + for (const environmentId of environmentIds) { | |
| 446 | + const result = mapAtomCommandResult( | |
| 447 | + await upsertKeybinding({ environmentId, input }), | |
| 448 | + () => undefined, | |
| 449 | + ); | |
| 450 | + if (result._tag === "Failure") { | |
| 451 | + reportFailure("Failed to save keybinding", result); | |
| 452 | + return result; | |
| 453 | + } | |
| 454 | + } | |
| 455 | + } else if (previousTarget) { | |
| 456 | + for (const environmentId of environmentIds) { | |
| 457 | + const result = mapAtomCommandResult( | |
| 458 | + await removeKeybinding({ environmentId, input: previousTarget }), | |
| 459 | + () => undefined, | |
| 460 | + ); | |
| 461 | + if (result._tag === "Failure") { | |
| 462 | + reportFailure("Failed to remove keybinding", result); | |
| 463 | + return result; | |
| 464 | + } | |
| 465 | + } | |
| 466 | + } | |
| 467 | + return updateResult; | |
| 468 | + } finally { | |
| 469 | + savingScriptsRef.current = false; | |
| 470 | + setIsSavingScripts(false); | |
| 471 | + } | |
| 472 | + }, | |
| 473 | + [ | |
| 474 | + group.memberProjects, | |
| 475 | + keybindings, | |
| 476 | + removeKeybinding, | |
| 477 | + reportFailure, | |
| 478 | + updateAllMembers, | |
| 479 | + upsertKeybinding, | |
| 480 | + ], | |
| 481 | + ); | |
| 482 | + | |
| 483 | + const submitScript = useCallback( | |
| 484 | + async ( | |
| 485 | + scriptId: string | null, | |
| 486 | + input: NewProjectScriptInput, | |
| 487 | + ): Promise<AtomCommandResult<void, unknown>> => { | |
| 488 | + if (scriptId === null) { | |
| 489 | + const nextId = nextProjectScriptId( | |
| 490 | + input.name, | |
| 491 | + scripts.map((script) => script.id), | |
| 492 | + ); | |
| 493 | + const nextScript = buildProjectScript(nextId, input); | |
| 494 | + const nextScripts = input.runOnWorktreeCreate | |
| 495 | + ? [ | |
| 496 | + ...scripts.map((script) => | |
| 497 | + script.runOnWorktreeCreate ? { ...script, runOnWorktreeCreate: false } : script, | |
| 498 | + ), | |
| 499 | + nextScript, | |
| 500 | + ] | |
| 501 | + : [...scripts, nextScript]; | |
| 502 | + return persistScripts(nextScripts, input.keybinding, commandForProjectScript(nextId)); | |
| 503 | + } | |
| 504 | + | |
| 505 | + const updatedScript = buildProjectScript(scriptId, input); | |
| 506 | + const nextScripts = scripts.map((script) => | |
| 507 | + script.id === scriptId | |
| 508 | + ? updatedScript | |
| 509 | + : input.runOnWorktreeCreate | |
| 510 | + ? { ...script, runOnWorktreeCreate: false } | |
| 511 | + : script, | |
| 512 | + ); | |
| 513 | + return persistScripts(nextScripts, input.keybinding, commandForProjectScript(scriptId)); | |
| 514 | + }, | |
| 515 | + [persistScripts, scripts], | |
| 516 | + ); | |
| 517 | + | |
| 518 | + const deleteScript = useCallback( | |
| 519 | + (scriptId: string) => { | |
| 520 | + const nextScripts = scripts.filter((script) => script.id !== scriptId); | |
| 521 | + void persistScripts(nextScripts, null, commandForProjectScript(scriptId)); | |
| 522 | + }, | |
| 523 | + [persistScripts, scripts], | |
| 524 | + ); | |
| 525 | + | |
| 526 | + const importFileScript = useCallback( | |
| 527 | + async (fileScript: T3ProjectFileScript) => { | |
| 528 | + const payload: NewProjectScriptInput = { | |
| 529 | + name: fileScript.name, | |
| 530 | + command: fileScript.command, | |
| 531 | + icon: fileScript.icon ?? "play", | |
| 532 | + runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false, | |
| 533 | + keybinding: null, | |
| 534 | + previewUrl: fileScript.previewUrl ?? null, | |
| 535 | + autoOpenPreview: fileScript.previewUrl ? (fileScript.autoOpenPreview ?? false) : false, | |
| 536 | + }; | |
| 537 | + const result = await submitScript(null, payload); | |
| 538 | + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { | |
| 539 | + const error = squashAtomCommandFailure(result); | |
| 540 | + setEditorRequest({ | |
| 541 | + scriptId: null, | |
| 542 | + initial: payload, | |
| 543 | + error: error instanceof Error ? error.message : "Failed to import action.", | |
| 544 | + }); | |
| 545 | + } | |
| 546 | + }, | |
| 547 | + [submitScript], | |
| 548 | + ); | |
| 549 | + | |
| 550 | + // ----- checkouts ----- | |
| 551 | + const renameMember = useCallback( | |
| 552 | + async (member: SidebarProjectGroupMember, nextTitle: string) => { | |
| 553 | + const title = nextTitle.trim(); | |
| 554 | + if (!title) { | |
| 555 | + toastManager.add({ type: "warning", title: "Project title cannot be empty" }); | |
| 556 | + return; | |
| 557 | + } | |
| 558 | + if (title === member.title) return; | |
| 559 | + const result = mapAtomCommandResult( | |
| 560 | + await updateProject({ | |
| 561 | + environmentId: member.environmentId, | |
| 562 | + input: { projectId: member.id, title }, | |
| 563 | + }), | |
| 564 | + () => undefined, | |
| 565 | + ); | |
| 566 | + reportFailure("Failed to rename project", result); | |
| 567 | + }, | |
| 568 | + [reportFailure, updateProject], | |
| 569 | + ); | |
| 570 | + | |
| 571 | + const updateGroupingPreference = useCallback( | |
| 572 | + (member: SidebarProjectGroupMember, selection: SidebarProjectGroupingMode | "inherit") => { | |
| 573 | + const overrideKey = deriveProjectGroupingOverrideKey(member); | |
| 574 | + const nextOverrides = { ...projectGroupingSettings.sidebarProjectGroupingOverrides }; | |
| 575 | + if (selection === "inherit") { | |
| 576 | + delete nextOverrides[overrideKey]; | |
| 577 | + } else { | |
| 578 | + nextOverrides[overrideKey] = selection; | |
| 579 | + } | |
| 580 | + updateClientSettings({ sidebarProjectGroupingOverrides: nextOverrides }); | |
| 581 | + }, | |
| 582 | + [projectGroupingSettings.sidebarProjectGroupingOverrides, updateClientSettings], | |
| 583 | + ); | |
| 584 | + | |
| 585 | + const removeMembers = useCallback( | |
| 586 | + async (members: ReadonlyArray<SidebarProjectGroupMember>) => { | |
| 587 | + const api = readLocalApi(); | |
| 588 | + if (!api) return; | |
| 589 | + | |
| 590 | + const memberKeys = new Set(members.map(memberKey)); | |
| 591 | + const projectThreads = threads.filter((thread) => | |
| 592 | + memberKeys.has(`${thread.environmentId}:${thread.projectId}`), | |
| 593 | + ); | |
| 594 | + const isWholeGroup = members.length === group.memberProjects.length; | |
| 595 | + const singleMember = members.length === 1 ? members[0]! : null; | |
| 596 | + const targetLabel = singleMember?.title ?? group.displayName; | |
| 597 | + const confirmed = await settlePromise(() => | |
| 598 | + api.dialogs.confirm( | |
| 599 | + [ | |
| 600 | + projectThreads.length > 0 | |
| 601 | + ? `Remove project "${targetLabel}" and delete its ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}?` | |
| 602 | + : `Remove project "${targetLabel}"?`, | |
| 603 | + ...(singleMember | |
| 604 | + ? [ | |
| 605 | + `Path: ${singleMember.workspaceRoot}`, | |
| 606 | + ...(singleMember.environmentLabel | |
| 607 | + ? [`Environment: ${singleMember.environmentLabel}`] | |
| 608 | + : []), | |
| 609 | + ] | |
| 610 | + : [`This removes ${members.length} grouped project entries.`]), | |
| 611 | + ...(projectThreads.length > 0 | |
| 612 | + ? ["This permanently clears conversation history for those threads."] | |
| 613 | + : []), | |
| 614 | + isWholeGroup | |
| 615 | + ? "This removes only the project entries, not the files on disk." | |
| 616 | + : "Other entries in this grouped project are unaffected.", | |
| 617 | + "This action cannot be undone.", | |
| 618 | + ].join("\n"), | |
| 619 | + ), | |
| 620 | + ); | |
| 621 | + if (confirmed._tag === "Failure" || !confirmed.value) return; | |
| 622 | + | |
| 623 | + const draftStore = useComposerDraftStore.getState(); | |
| 624 | + for (const member of members) { | |
| 625 | + const memberThreads = projectThreads.filter( | |
| 626 | + (thread) => | |
| 627 | + thread.environmentId === member.environmentId && thread.projectId === member.id, | |
| 628 | + ); | |
| 629 | + const result = mapAtomCommandResult( | |
| 630 | + await deleteProject({ | |
| 631 | + environmentId: member.environmentId, | |
| 632 | + input: { | |
| 633 | + projectId: member.id, | |
| 634 | + ...(memberThreads.length > 0 ? { force: true } : {}), | |
| 635 | + }, | |
| 636 | + }), | |
| 637 | + () => undefined, | |
| 638 | + ); | |
| 639 | + if (result._tag === "Failure") { | |
| 640 | + reportFailure(`Failed to remove "${member.title}"`, result); | |
| 641 | + return; | |
| 642 | + } | |
| 643 | + const projectRef = scopeProjectRef(member.environmentId, member.id); | |
| 644 | + const projectDraftThread = draftStore.getDraftThreadByProjectRef(projectRef); | |
| 645 | + if (projectDraftThread) { | |
| 646 | + draftStore.clearDraftThread(projectDraftThread.draftId); | |
| 647 | + } | |
| 648 | + draftStore.clearProjectDraftThreadId(projectRef); | |
| 649 | + } | |
| 650 | + | |
| 651 | + if (isWholeGroup) { | |
| 652 | + void navigate({ to: "/settings/projects", replace: true }); | |
| 653 | + } | |
| 654 | + }, | |
| 655 | + [ | |
| 656 | + deleteProject, | |
| 657 | + group.displayName, | |
| 658 | + group.memberProjects.length, | |
| 659 | + navigate, | |
| 660 | + reportFailure, | |
| 661 | + threads, | |
| 662 | + ], | |
| 663 | + ); | |
| 664 | + | |
| 665 | + const repositoryLine = | |
| 666 | + representative.repositoryIdentity?.displayName ?? | |
| 667 | + representative.repositoryIdentity?.canonicalKey ?? | |
| 668 | + "No git remote detected"; | |
| 669 | + const environmentCount = new Set(group.memberProjects.map((member) => member.environmentId)).size; | |
| 670 | + | |
| 671 | + return ( | |
| 672 | + <SettingsPageContainer className="gap-10"> | |
| 673 | + <div className="flex items-center gap-3 px-3 sm:px-4"> | |
| 674 | + <ProjectFavicon | |
| 675 | + environmentId={group.environmentId} | |
| 676 | + cwd={group.workspaceRoot} | |
| 677 | + className="size-8 shrink-0" | |
| 678 | + /> | |
| 679 | + <div className="min-w-0 flex-1"> | |
| 680 | + <h1 className="truncate text-xl font-semibold tracking-[-0.02em] text-foreground"> | |
| 681 | + {group.displayName} | |
| 682 | + </h1> | |
| 683 | + <p className="truncate text-[13px] text-muted-foreground"> | |
| 684 | + {repositoryLine} | |
| 685 | + {" · "} | |
| 686 | + {group.memberProjects.length === 1 | |
| 687 | + ? "1 checkout" | |
| 688 | + : `${group.memberProjects.length} checkouts`} | |
| 689 | + {environmentCount > 1 ? ` across ${environmentCount} environments` : ""} | |
| 690 | + {" · "} | |
| 691 | + {groupThreadCount === 1 ? "1 thread" : `${groupThreadCount} threads`} | |
| 692 | + </p> | |
| 693 | + </div> | |
| 694 | + <Select value={group.projectKey} onValueChange={(value) => onSelectProject(String(value))}> | |
| 695 | + <SelectTrigger className="sm:hidden" aria-label="Switch project"> | |
| 696 | + <SelectValue>{group.displayName}</SelectValue> | |
| 697 | + </SelectTrigger> | |
| 698 | + <SelectPopup align="end" alignItemWithTrigger={false}> | |
| 699 | + {groups.map((candidate) => ( | |
| 700 | + <SelectItem key={candidate.projectKey} hideIndicator value={candidate.projectKey}> | |
| 701 | + {candidate.displayName} | |
| 702 | + </SelectItem> | |
| 703 | + ))} | |
| 704 | + </SelectPopup> | |
| 705 | + </Select> | |
| 706 | + </div> | |
| 707 | + | |
| 708 | + <SettingsSection title="Model"> | |
| 709 | + <SettingsRow | |
| 710 | + id="project-default-model" | |
| 711 | + title="Default model" | |
| 712 | + description="New threads in this project start with this model. Applies to every checkout in this group." | |
| 713 | + resetAction={ | |
| 714 | + storedSelection !== null ? ( | |
| 715 | + <SettingResetButton | |
| 716 | + label="project default model" | |
| 717 | + onClick={() => setDefaultModel(null)} | |
| 718 | + /> | |
| 719 | + ) : null | |
| 720 | + } | |
| 721 | + control={ | |
| 722 | + resolvedSelection && activeEntry ? ( | |
| 723 | + <div className="flex flex-wrap items-center justify-end gap-1.5"> | |
| 724 | + <ProviderModelPicker | |
| 725 | + activeInstanceId={resolvedSelection.instanceId} | |
| 726 | + model={resolvedSelection.model} | |
| 727 | + lockedProvider={null} | |
| 728 | + instanceEntries={instanceEntries} | |
| 729 | + modelOptionsByInstance={modelOptionsByInstance} | |
| 730 | + triggerVariant="outline" | |
| 731 | + triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground" | |
| 732 | + onInstanceModelChange={(instanceId, model) => { | |
| 733 | + setDefaultModel(createModelSelection(instanceId, model)); | |
| 734 | + }} | |
| 735 | + /> | |
| 736 | + <TraitsPicker | |
| 737 | + provider={activeEntry.driverKind as ProviderDriverKind} | |
| 738 | + models={activeEntry.models} | |
| 739 | + model={resolvedSelection.model} | |
| 740 | + prompt="" | |
| 741 | + onPromptChange={() => {}} | |
| 742 | + modelOptions={resolvedSelection.options ?? []} | |
| 743 | + allowPromptInjectedEffort={false} | |
| 744 | + triggerVariant="outline" | |
| 745 | + triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground" | |
| 746 | + onModelOptionsChange={(nextOptions) => { | |
| 747 | + setDefaultModel( | |
| 748 | + createModelSelection( | |
| 749 | + resolvedSelection.instanceId, | |
| 750 | + resolvedSelection.model, | |
| 751 | + nextOptions, | |
| 752 | + ), | |
| 753 | + ); | |
| 754 | + }} | |
| 755 | + /> | |
| 756 | + </div> | |
| 757 | + ) : ( | |
| 758 | + <span className="text-sm text-muted-foreground">No providers available</span> | |
| 759 | + ) | |
| 760 | + } | |
| 761 | + /> | |
| 762 | + </SettingsSection> | |
| 763 | + | |
| 764 | + <SettingsSection | |
| 765 | + id="project-scripts" | |
| 766 | + title="Scripts" | |
| 767 | + headerAction={ | |
| 768 | + <Button | |
| 769 | + size="xs" | |
| 770 | + variant="outline" | |
| 771 | + disabled={isSavingScripts} | |
| 772 | + onClick={() => | |
| 773 | + setEditorRequest({ scriptId: null, initial: EMPTY_PROJECT_SCRIPT_INPUT }) | |
| 774 | + } | |
| 775 | + > | |
| 776 | + <PlusIcon className="size-3.5" /> | |
| 777 | + Add action | |
| 778 | + </Button> | |
| 779 | + } | |
| 780 | + > | |
| 781 | + {scripts.length === 0 ? ( | |
| 782 | + <p className="px-3 py-2 text-sm text-muted-foreground sm:px-4"> | |
| 783 | + No scripts yet. Scripts run in a project terminal from the thread top bar; one script | |
| 784 | + can run automatically when a worktree is created. | |
| 785 | + </p> | |
| 786 | + ) : ( | |
| 787 | + <div className="space-y-0.5"> | |
| 788 | + {scripts.map((script) => { | |
| 789 | + const shortcutLabel = shortcutLabelForCommand( | |
| 790 | + keybindings, | |
| 791 | + commandForProjectScript(script.id), | |
| 792 | + ); | |
| 793 | + return ( | |
| 794 | + <div | |
| 795 | + key={script.id} | |
| 796 | + className="group flex min-w-0 items-center gap-3 rounded-lg px-3 py-2 hover:bg-accent/50 sm:px-4" | |
| 797 | + > | |
| 798 | + <ScriptIcon | |
| 799 | + icon={script.icon} | |
| 800 | + className="size-4 shrink-0 text-muted-foreground" | |
| 801 | + /> | |
| 802 | + <span className="shrink-0 text-sm font-medium text-foreground"> | |
| 803 | + {script.name} | |
| 804 | + </span> | |
| 805 | + {script.runOnWorktreeCreate ? ( | |
| 806 | + <span className="shrink-0 rounded-sm border border-border/60 px-1.5 py-px text-[11px] text-muted-foreground"> | |
| 807 | + setup | |
| 808 | + </span> | |
| 809 | + ) : null} | |
| 810 | + {script.previewUrl ? ( | |
| 811 | + <span className="shrink-0 rounded-sm border border-border/60 px-1.5 py-px text-[11px] text-muted-foreground max-sm:hidden"> | |
| 812 | + preview · desktop only | |
| 813 | + </span> | |
| 814 | + ) : null} | |
| 815 | + <span className="min-w-0 flex-1 truncate font-mono text-xs text-muted-foreground/80"> | |
| 816 | + {script.command} | |
| 817 | + </span> | |
| 818 | + {shortcutLabel ? ( | |
| 819 | + <span className="shrink-0 text-xs text-muted-foreground">{shortcutLabel}</span> | |
| 820 | + ) : null} | |
| 821 | + <Button | |
| 822 | + size="icon-xs" | |
| 823 | + variant="ghost" | |
| 824 | + className="shrink-0 text-muted-foreground opacity-0 group-hover:opacity-100 group-focus-within:opacity-100" | |
| 825 | + aria-label={`Edit ${script.name}`} | |
| 826 | + disabled={isSavingScripts} | |
| 827 | + onClick={() => setEditorRequest(editorRequestForScript(script, keybindings))} | |
| 828 | + > | |
| 829 | + <SettingsIcon className="size-3.5" /> | |
| 830 | + </Button> | |
| 831 | + </div> | |
| 832 | + ); | |
| 833 | + })} | |
| 834 | + </div> | |
| 835 | + )} | |
| 836 | + {t3File.status === "invalid" ? ( | |
| 837 | + <SettingsRow | |
| 838 | + title="t3.json is invalid" | |
| 839 | + description="A t3.json exists at the workspace root but fails to parse, so every script and icon it declares is ignored. Check the JSON syntax and icon values." | |
| 840 | + className="text-warning" | |
| 841 | + /> | |
| 842 | + ) : null} | |
| 843 | + {importableScripts.length > 0 ? ( | |
| 844 | + <SettingsRow | |
| 845 | + title="Import from t3.json" | |
| 846 | + description={`${importableScripts.length} script${importableScripts.length === 1 ? "" : "s"} declared in this repo's t3.json ${importableScripts.length === 1 ? "is" : "are"} not set up yet.`} | |
| 847 | + control={ | |
| 848 | + <div className="flex flex-wrap justify-end gap-1.5"> | |
| 849 | + {importableScripts.map((fileScript) => ( | |
| 850 | + <Button | |
| 851 | + key={`${fileScript.name} ${fileScript.command}`} | |
| 852 | + size="xs" | |
| 853 | + variant="outline" | |
| 854 | + disabled={isSavingScripts} | |
| 855 | + onClick={() => void importFileScript(fileScript)} | |
| 856 | + > | |
| 857 | + <ScriptIcon icon={fileScript.icon ?? "play"} className="size-3.5" /> | |
| 858 | + {fileScript.name} | |
| 859 | + </Button> | |
| 860 | + ))} | |
| 861 | + </div> | |
| 862 | + } | |
| 863 | + /> | |
| 864 | + ) : null} | |
| 865 | + </SettingsSection> | |
| 866 | + | |
| 867 | + <SettingsSection id="project-checkouts" title="Checkouts"> | |
| 868 | + <div className="space-y-2 px-3 sm:px-4"> | |
| 869 | + {group.memberProjects.map((member) => { | |
| 870 | + const threadCount = threadCountByMember.get(memberKey(member)) ?? 0; | |
| 871 | + const groupingOverride = | |
| 872 | + projectGroupingSettings.sidebarProjectGroupingOverrides?.[ | |
| 873 | + deriveProjectGroupingOverrideKey(member) | |
| 874 | + ] ?? "inherit"; | |
| 875 | + return ( | |
| 876 | + <div | |
| 877 | + key={member.physicalProjectKey} | |
| 878 | + className="space-y-3 rounded-lg border border-border/50 p-3" | |
| 879 | + > | |
| 880 | + <div className="flex min-w-0 items-center gap-2"> | |
| 881 | + <ServerIcon className="size-3.5 shrink-0 text-muted-foreground" /> | |
| 882 | + <span className="min-w-0 truncate text-sm font-medium text-foreground"> | |
| 883 | + {member.environmentLabel ?? "Current environment"} | |
| 884 | + </span> | |
| 885 | + <span className="ms-auto shrink-0 text-xs text-muted-foreground"> | |
| 886 | + {threadCount === 1 ? "1 thread" : `${threadCount} threads`} | |
| 887 | + </span> | |
| 888 | + {group.memberProjects.length > 1 ? ( | |
| 889 | + <Button | |
| 890 | + size="icon-xs" | |
| 891 | + variant="ghost" | |
| 892 | + className="shrink-0 text-muted-foreground hover:text-destructive-foreground" | |
| 893 | + aria-label={`Remove checkout ${member.workspaceRoot}`} | |
| 894 | + onClick={() => void removeMembers([member])} | |
| 895 | + > | |
| 896 | + <Trash2Icon className="size-3.5" /> | |
| 897 | + </Button> | |
| 898 | + ) : null} | |
| 899 | + </div> | |
| 900 | + <div className="flex min-w-0 items-center gap-1.5"> | |
| 901 | + <FolderIcon className="size-3.5 shrink-0 text-muted-foreground/70" /> | |
| 902 | + <span className="min-w-0 truncate font-mono text-xs text-muted-foreground"> | |
| 903 | + {member.workspaceRoot} | |
| 904 | + </span> | |
| 905 | + <Button | |
| 906 | + size="icon-xs" | |
| 907 | + variant="ghost" | |
| 908 | + className="size-4 shrink-0 rounded-sm" | |
| 909 | + aria-label="Copy project path" | |
| 910 | + onClick={() => | |
| 911 | + copyPathToClipboard(member.workspaceRoot, { path: member.workspaceRoot }) | |
| 912 | + } | |
| 913 | + > | |
| 914 | + <CopyIcon className="size-3" /> | |
| 915 | + </Button> | |
| 916 | + </div> | |
| 917 | + <div className="grid gap-3 sm:grid-cols-2"> | |
| 918 | + <label className="grid min-w-0 gap-1.5"> | |
| 919 | + <span className="text-xs font-medium text-muted-foreground">Name</span> | |
| 920 | + <Input | |
| 921 | + key={`${member.physicalProjectKey}:${member.title}`} | |
| 922 | + aria-label={`Project name in ${member.environmentLabel ?? "current environment"}`} | |
| 923 | + defaultValue={member.title} | |
| 924 | + onBlur={(event) => { | |
| 925 | + void renameMember(member, event.currentTarget.value); | |
| 926 | + }} | |
| 927 | + onKeyDown={(event) => { | |
| 928 | + if (event.key === "Enter") event.currentTarget.blur(); | |
| 929 | + }} | |
| 930 | + /> | |
| 931 | + </label> | |
| 932 | + <label className="grid min-w-0 gap-1.5"> | |
| 933 | + <span className="text-xs font-medium text-muted-foreground">Grouping rule</span> | |
| 934 | + <Select | |
| 935 | + value={groupingOverride} | |
| 936 | + onValueChange={(value) => { | |
| 937 | + if ( | |
| 938 | + value === "inherit" || | |
| 939 | + value === "repository" || | |
| 940 | + value === "repository_path" || | |
| 941 | + value === "separate" | |
| 942 | + ) { | |
| 943 | + updateGroupingPreference(member, value); | |
| 944 | + } | |
| 945 | + }} | |
| 946 | + > | |
| 947 | + <SelectTrigger | |
| 948 | + className="w-full" | |
| 949 | + aria-label={`Grouping rule for ${member.environmentLabel ?? "current environment"}`} | |
| 950 | + > | |
| 951 | + <SelectValue> | |
| 952 | + {groupingOverride === "inherit" | |
| 953 | + ? `Default (${PROJECT_GROUPING_MODE_LABELS[projectGroupingSettings.sidebarProjectGroupingMode]})` | |
| 954 | + : PROJECT_GROUPING_MODE_LABELS[groupingOverride]} | |
| 955 | + </SelectValue> | |
| 956 | + </SelectTrigger> | |
| 957 | + <SelectPopup align="start" alignItemWithTrigger={false}> | |
| 958 | + <SelectItem hideIndicator value="inherit"> | |
| 959 | + Use global default | |
| 960 | + </SelectItem> | |
| 961 | + <SelectItem hideIndicator value="repository"> | |
| 962 | + {PROJECT_GROUPING_MODE_LABELS.repository} | |
| 963 | + </SelectItem> | |
| 964 | + <SelectItem hideIndicator value="repository_path"> | |
| 965 | + {PROJECT_GROUPING_MODE_LABELS.repository_path} | |
| 966 | + </SelectItem> | |
| 967 | + <SelectItem hideIndicator value="separate"> | |
| 968 | + {PROJECT_GROUPING_MODE_LABELS.separate} | |
| 969 | + </SelectItem> | |
| 970 | + </SelectPopup> | |
| 971 | + </Select> | |
| 972 | + </label> | |
| 973 | + </div> | |
| 974 | + </div> | |
| 975 | + ); | |
| 976 | + })} | |
| 977 | + </div> | |
| 978 | + </SettingsSection> | |
| 979 | + | |
| 980 | + <SettingsSection title="Danger"> | |
| 981 | + <SettingsRow | |
| 982 | + title={ | |
| 983 | + group.memberProjects.length > 1 ? "Remove this project everywhere" : "Remove project" | |
| 984 | + } | |
| 985 | + description={ | |
| 986 | + group.memberProjects.length > 1 | |
| 987 | + ? `Deletes all ${group.memberProjects.length} checkout entries and their threads on every machine. Files on disk are not touched.` | |
| 988 | + : "Deletes the project entry and its threads. Files on disk are not touched." | |
| 989 | + } | |
| 990 | + control={ | |
| 991 | + <Button | |
| 992 | + variant="destructive-outline" | |
| 993 | + onClick={() => void removeMembers(group.memberProjects)} | |
| 994 | + > | |
| 995 | + <Trash2Icon /> | |
| 996 | + {group.memberProjects.length > 1 ? "Remove all entries" : "Remove project"} | |
| 997 | + </Button> | |
| 998 | + } | |
| 999 | + /> | |
| 1000 | + </SettingsSection> | |
| 1001 | + | |
| 1002 | + <ProjectScriptEditorDialog | |
| 1003 | + request={editorRequest} | |
| 1004 | + scripts={scripts} | |
| 1005 | + onSubmit={submitScript} | |
| 1006 | + onDelete={deleteScript} | |
| 1007 | + onClose={() => setEditorRequest(null)} | |
| 1008 | + /> | |
| 1009 | + </SettingsPageContainer> | |
| 1010 | + ); | |
| 1011 | +} | |
| @@ -11,6 +11,7 @@ import { · 10 unmodified lines | ||
| 11 | 11 | ArchiveIcon, |
| 12 | 12 | ArrowLeftIcon, |
| 13 | 13 | BotIcon, |
| 14 | + FolderIcon, | |
| 14 | 15 | GitBranchIcon, |
| 15 | 16 | KeyboardIcon, |
| 16 | 17 | Link2Icon, |
| @@ -48,6 +49,7 @@ const SETTINGS_SECTION_ICONS: Readonly< · 31 unmodified lines | ||
| 48 | 49 | "/settings/general": Settings2Icon, |
| 49 | 50 | "/settings/appearance": PaletteIcon, |
| 50 | 51 | "/settings/keybindings": KeyboardIcon, |
| 52 | + "/settings/projects": FolderIcon, | |
| 51 | 53 | "/settings/providers": BotIcon, |
| 52 | 54 | "/settings/source-control": GitBranchIcon, |
| 53 | 55 | "/settings/connections": Link2Icon, |
| @@ -279,7 +281,9 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { · 225 unmodified lines | ||
| 279 | 281 | )) |
| 280 | 282 | : SETTINGS_NAV_ITEMS.map((item) => { |
| 281 | 283 | const Icon = item.icon; |
| 282 | − const isActive = pathname === item.to; | |
| 284 | + // Prefix match keeps the section active on nested routes | |
| 285 | + // like /settings/projects/$projectKey. | |
| 286 | + const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`); | |
| 283 | 287 | return ( |
| 284 | 288 | <SidebarMenuItem key={item.to}> |
| 285 | 289 | <SidebarMenuButton |
| @@ -2,6 +2,7 @@ export type SettingsPath = · 1 unmodified line | ||
| 2 | 2 | | "/settings/general" |
| 3 | 3 | | "/settings/appearance" |
| 4 | 4 | | "/settings/keybindings" |
| 5 | + | "/settings/projects" | |
| 5 | 6 | | "/settings/providers" |
| 6 | 7 | | "/settings/source-control" |
| 7 | 8 | | "/settings/connections" |
| @@ -22,6 +23,7 @@ export const SETTINGS_SECTION_LABELS: Readonly<Record<SettingsPath, string>> = { · 14 unmodified lines | ||
| 22 | 23 | "/settings/general": "General", |
| 23 | 24 | "/settings/appearance": "Appearance", |
| 24 | 25 | "/settings/keybindings": "Keybindings", |
| 26 | + "/settings/projects": "Projects", | |
| 25 | 27 | "/settings/providers": "Providers", |
| 26 | 28 | "/settings/source-control": "Source Control", |
| 27 | 29 | "/settings/connections": "Connections", |
| @@ -174,6 +176,26 @@ export const SETTINGS_SEARCH_ITEMS = [ · 146 unmodified lines | ||
| 174 | 176 | title: "Keybindings", |
| 175 | 177 | to: "/settings/keybindings", |
| 176 | 178 | }, |
| 179 | + { | |
| 180 | + id: "projects", | |
| 181 | + title: "Projects", | |
| 182 | + to: "/settings/projects", | |
| 183 | + }, | |
| 184 | + { | |
| 185 | + id: "project-default-model", | |
| 186 | + title: "Project default model", | |
| 187 | + to: "/settings/projects", | |
| 188 | + }, | |
| 189 | + { | |
| 190 | + id: "project-scripts", | |
| 191 | + title: "Project scripts", | |
| 192 | + to: "/settings/projects", | |
| 193 | + }, | |
| 194 | + { | |
| 195 | + id: "project-checkouts", | |
| 196 | + title: "Project checkouts", | |
| 197 | + to: "/settings/projects", | |
| 198 | + }, | |
| 177 | 199 | { |
| 178 | 200 | id: "providers", |
| 179 | 201 | title: "Providers", |
| @@ -14,6 +14,41 @@ const decodeT3ProjectFile = Schema.decodeExit(T3ProjectFileFromJson); · 13 unmodified lines | ||
| 14 | 14 | |
| 15 | 15 | const NO_SCRIPTS: ReadonlyArray<T3ProjectFileScript> = []; |
| 16 | 16 | |
| 17 | +export interface T3ProjectFileState { | |
| 18 | + /** | |
| 19 | + * - `valid`: t3.json exists and decoded. | |
| 20 | + * - `invalid`: t3.json exists but fails to decode (the server then ignores | |
| 21 | + * the whole file, including `iconPath` and every script). | |
| 22 | + * - `missing`: no readable t3.json at the workspace root. | |
| 23 | + * - `loading`: the file query has not settled yet. | |
| 24 | + */ | |
| 25 | + status: "loading" | "missing" | "invalid" | "valid"; | |
| 26 | + scripts: ReadonlyArray<T3ProjectFileScript>; | |
| 27 | +} | |
| 28 | + | |
| 29 | +/** | |
| 30 | + * Decoded state of the project's checked-in `t3.json`, including whether the | |
| 31 | + * file exists but is broken — which the runtime otherwise swallows silently. | |
| 32 | + */ | |
| 33 | +export function useT3ProjectFileState( | |
| 34 | + environmentId: EnvironmentId, | |
| 35 | + cwd: string | null, | |
| 36 | +): T3ProjectFileState { | |
| 37 | + const query = useProjectFileQuery(environmentId, cwd ?? "", T3_PROJECT_FILE_NAME, cwd !== null); | |
| 38 | + const contents = query.data && !query.data.truncated ? query.data.contents : null; | |
| 39 | + const isPending = query.isPending; | |
| 40 | + return useMemo(() => { | |
| 41 | + if (contents === null) { | |
| 42 | + return { status: isPending ? "loading" : "missing", scripts: NO_SCRIPTS } as const; | |
| 43 | + } | |
| 44 | + const decoded = decodeT3ProjectFile(contents); | |
| 45 | + if (Exit.isFailure(decoded)) { | |
| 46 | + return { status: "invalid", scripts: NO_SCRIPTS } as const; | |
| 47 | + } | |
| 48 | + return { status: "valid", scripts: decoded.value.scripts ?? NO_SCRIPTS } as const; | |
| 49 | + }, [contents, isPending]); | |
| 50 | +} | |
| 51 | + | |
| 17 | 52 | /** |
| 18 | 53 | * Scripts declared in the project's checked-in `t3.json`, offered in the |
| 19 | 54 | * scripts menu for import. Missing, truncated, or invalid files resolve to |
| @@ -23,12 +58,5 @@ export function useT3ProjectFileScripts( · 3 unmodified lines | ||
| 23 | 58 | environmentId: EnvironmentId, |
| 24 | 59 | cwd: string | null, |
| 25 | 60 | ): ReadonlyArray<T3ProjectFileScript> { |
| 26 | − const query = useProjectFileQuery(environmentId, cwd ?? "", T3_PROJECT_FILE_NAME, cwd !== null); | |
| 27 | − const contents = query.data && !query.data.truncated ? query.data.contents : null; | |
| 28 | − return useMemo(() => { | |
| 29 | − if (contents === null) return NO_SCRIPTS; | |
| 30 | − const decoded = decodeT3ProjectFile(contents); | |
| 31 | − if (Exit.isFailure(decoded)) return NO_SCRIPTS; | |
| 32 | − return decoded.value.scripts ?? NO_SCRIPTS; | |
| 33 | − }, [contents]); | |
| 61 | + return useT3ProjectFileState(environmentId, cwd).scripts; | |
| 34 | 62 | } |
| @@ -17,6 +17,7 @@ import { Route as ChatRouteImport } from './routes/_chat' · 16 unmodified lines | ||
| 17 | 17 | import { Route as ChatIndexRouteImport } from './routes/_chat.index' |
| 18 | 18 | import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control' |
| 19 | 19 | import { Route as SettingsProvidersRouteImport } from './routes/settings.providers' |
| 20 | +import { Route as SettingsProjectsRouteImport } from './routes/settings.projects' | |
| 20 | 21 | import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybindings' |
| 21 | 22 | import { Route as SettingsGeneralRouteImport } from './routes/settings.general' |
| 22 | 23 | import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics' |
| @@ -24,6 +25,7 @@ import { Route as SettingsConnectionsRouteImport } from './routes/settings.conne · 1 unmodified line | ||
| 24 | 25 | import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' |
| 25 | 26 | import { Route as SettingsAppearanceRouteImport } from './routes/settings.appearance' |
| 26 | 27 | import { Route as ConnectCallbackRouteImport } from './routes/connect_.callback' |
| 28 | +import { Route as SettingsProjectsProjectKeyRouteImport } from './routes/settings.projects_.$projectKey' | |
| 27 | 29 | import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' |
| 28 | 30 | import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' |
| 29 | 31 | |
| @@ -66,6 +68,11 @@ const SettingsProvidersRoute = SettingsProvidersRouteImport.update({ · 36 unmodified lines | ||
| 66 | 68 | path: '/providers', |
| 67 | 69 | getParentRoute: () => SettingsRoute, |
| 68 | 70 | } as any) |
| 71 | +const SettingsProjectsRoute = SettingsProjectsRouteImport.update({ | |
| 72 | + id: '/projects', | |
| 73 | + path: '/projects', | |
| 74 | + getParentRoute: () => SettingsRoute, | |
| 75 | +} as any) | |
| 69 | 76 | const SettingsKeybindingsRoute = SettingsKeybindingsRouteImport.update({ |
| 70 | 77 | id: '/keybindings', |
| 71 | 78 | path: '/keybindings', |
| @@ -101,6 +108,12 @@ const ConnectCallbackRoute = ConnectCallbackRouteImport.update({ · 29 unmodified lines | ||
| 101 | 108 | path: '/connect/callback', |
| 102 | 109 | getParentRoute: () => rootRouteImport, |
| 103 | 110 | } as any) |
| 111 | +const SettingsProjectsProjectKeyRoute = | |
| 112 | + SettingsProjectsProjectKeyRouteImport.update({ | |
| 113 | + id: '/projects_/$projectKey', | |
| 114 | + path: '/projects/$projectKey', | |
| 115 | + getParentRoute: () => SettingsRoute, | |
| 116 | + } as any) | |
| 104 | 117 | const ChatDraftDraftIdRoute = ChatDraftDraftIdRouteImport.update({ |
| 105 | 118 | id: '/draft/$draftId', |
| 106 | 119 | path: '/draft/$draftId', |
| @@ -126,10 +139,12 @@ export interface FileRoutesByFullPath { · 19 unmodified lines | ||
| 126 | 139 | '/settings/diagnostics': typeof SettingsDiagnosticsRoute |
| 127 | 140 | '/settings/general': typeof SettingsGeneralRoute |
| 128 | 141 | '/settings/keybindings': typeof SettingsKeybindingsRoute |
| 142 | + '/settings/projects': typeof SettingsProjectsRoute | |
| 129 | 143 | '/settings/providers': typeof SettingsProvidersRoute |
| 130 | 144 | '/settings/source-control': typeof SettingsSourceControlRoute |
| 131 | 145 | '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute |
| 132 | 146 | '/draft/$draftId': typeof ChatDraftDraftIdRoute |
| 147 | + '/settings/projects/$projectKey': typeof SettingsProjectsProjectKeyRoute | |
| 133 | 148 | } |
| 134 | 149 | export interface FileRoutesByTo { |
| 135 | 150 | '/connect': typeof ConnectRoute |
| @@ -143,11 +158,13 @@ export interface FileRoutesByTo { · 7 unmodified lines | ||
| 143 | 158 | '/settings/diagnostics': typeof SettingsDiagnosticsRoute |
| 144 | 159 | '/settings/general': typeof SettingsGeneralRoute |
| 145 | 160 | '/settings/keybindings': typeof SettingsKeybindingsRoute |
| 161 | + '/settings/projects': typeof SettingsProjectsRoute | |
| 146 | 162 | '/settings/providers': typeof SettingsProvidersRoute |
| 147 | 163 | '/settings/source-control': typeof SettingsSourceControlRoute |
| 148 | 164 | '/': typeof ChatIndexRoute |
| 149 | 165 | '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute |
| 150 | 166 | '/draft/$draftId': typeof ChatDraftDraftIdRoute |
| 167 | + '/settings/projects/$projectKey': typeof SettingsProjectsProjectKeyRoute | |
| 151 | 168 | } |
| 152 | 169 | export interface FileRoutesById { |
| 153 | 170 | __root__: typeof rootRouteImport |
| @@ -163,11 +180,13 @@ export interface FileRoutesById { · 9 unmodified lines | ||
| 163 | 180 | '/settings/diagnostics': typeof SettingsDiagnosticsRoute |
| 164 | 181 | '/settings/general': typeof SettingsGeneralRoute |
| 165 | 182 | '/settings/keybindings': typeof SettingsKeybindingsRoute |
| 183 | + '/settings/projects': typeof SettingsProjectsRoute | |
| 166 | 184 | '/settings/providers': typeof SettingsProvidersRoute |
| 167 | 185 | '/settings/source-control': typeof SettingsSourceControlRoute |
| 168 | 186 | '/_chat/': typeof ChatIndexRoute |
| 169 | 187 | '/_chat/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute |
| 170 | 188 | '/_chat/draft/$draftId': typeof ChatDraftDraftIdRoute |
| 189 | + '/settings/projects_/$projectKey': typeof SettingsProjectsProjectKeyRoute | |
| 171 | 190 | } |
| 172 | 191 | export interface FileRouteTypes { |
| 173 | 192 | fileRoutesByFullPath: FileRoutesByFullPath |
| @@ -184,10 +203,12 @@ export interface FileRouteTypes { · 10 unmodified lines | ||
| 184 | 203 | | '/settings/diagnostics' |
| 185 | 204 | | '/settings/general' |
| 186 | 205 | | '/settings/keybindings' |
| 206 | + | '/settings/projects' | |
| 187 | 207 | | '/settings/providers' |
| 188 | 208 | | '/settings/source-control' |
| 189 | 209 | | '/$environmentId/$threadId' |
| 190 | 210 | | '/draft/$draftId' |
| 211 | + | '/settings/projects/$projectKey' | |
| 191 | 212 | fileRoutesByTo: FileRoutesByTo |
| 192 | 213 | to: |
| 193 | 214 | | '/connect' |
| @@ -201,11 +222,13 @@ export interface FileRouteTypes { · 7 unmodified lines | ||
| 201 | 222 | | '/settings/diagnostics' |
| 202 | 223 | | '/settings/general' |
| 203 | 224 | | '/settings/keybindings' |
| 225 | + | '/settings/projects' | |
| 204 | 226 | | '/settings/providers' |
| 205 | 227 | | '/settings/source-control' |
| 206 | 228 | | '/' |
| 207 | 229 | | '/$environmentId/$threadId' |
| 208 | 230 | | '/draft/$draftId' |
| 231 | + | '/settings/projects/$projectKey' | |
| 209 | 232 | id: |
| 210 | 233 | | '__root__' |
| 211 | 234 | | '/_chat' |
| @@ -220,11 +243,13 @@ export interface FileRouteTypes { · 8 unmodified lines | ||
| 220 | 243 | | '/settings/diagnostics' |
| 221 | 244 | | '/settings/general' |
| 222 | 245 | | '/settings/keybindings' |
| 246 | + | '/settings/projects' | |
| 223 | 247 | | '/settings/providers' |
| 224 | 248 | | '/settings/source-control' |
| 225 | 249 | | '/_chat/' |
| 226 | 250 | | '/_chat/$environmentId/$threadId' |
| 227 | 251 | | '/_chat/draft/$draftId' |
| 252 | + | '/settings/projects_/$projectKey' | |
| 228 | 253 | fileRoutesById: FileRoutesById |
| 229 | 254 | } |
| 230 | 255 | export interface RootRouteChildren { |
| @@ -294,6 +319,13 @@ declare module '@tanstack/react-router' { · 63 unmodified lines | ||
| 294 | 319 | preLoaderRoute: typeof SettingsProvidersRouteImport |
| 295 | 320 | parentRoute: typeof SettingsRoute |
| 296 | 321 | } |
| 322 | + '/settings/projects': { | |
| 323 | + id: '/settings/projects' | |
| 324 | + path: '/projects' | |
| 325 | + fullPath: '/settings/projects' | |
| 326 | + preLoaderRoute: typeof SettingsProjectsRouteImport | |
| 327 | + parentRoute: typeof SettingsRoute | |
| 328 | + } | |
| 297 | 329 | '/settings/keybindings': { |
| 298 | 330 | id: '/settings/keybindings' |
| 299 | 331 | path: '/keybindings' |
| @@ -343,6 +375,13 @@ declare module '@tanstack/react-router' { · 43 unmodified lines | ||
| 343 | 375 | preLoaderRoute: typeof ConnectCallbackRouteImport |
| 344 | 376 | parentRoute: typeof rootRouteImport |
| 345 | 377 | } |
| 378 | + '/settings/projects_/$projectKey': { | |
| 379 | + id: '/settings/projects_/$projectKey' | |
| 380 | + path: '/projects/$projectKey' | |
| 381 | + fullPath: '/settings/projects/$projectKey' | |
| 382 | + preLoaderRoute: typeof SettingsProjectsProjectKeyRouteImport | |
| 383 | + parentRoute: typeof SettingsRoute | |
| 384 | + } | |
| 346 | 385 | '/_chat/draft/$draftId': { |
| 347 | 386 | id: '/_chat/draft/$draftId' |
| 348 | 387 | path: '/draft/$draftId' |
| @@ -381,8 +420,10 @@ interface SettingsRouteChildren { · 32 unmodified lines | ||
| 381 | 420 | SettingsDiagnosticsRoute: typeof SettingsDiagnosticsRoute |
| 382 | 421 | SettingsGeneralRoute: typeof SettingsGeneralRoute |
| 383 | 422 | SettingsKeybindingsRoute: typeof SettingsKeybindingsRoute |
| 423 | + SettingsProjectsRoute: typeof SettingsProjectsRoute | |
| 384 | 424 | SettingsProvidersRoute: typeof SettingsProvidersRoute |
| 385 | 425 | SettingsSourceControlRoute: typeof SettingsSourceControlRoute |
| 426 | + SettingsProjectsProjectKeyRoute: typeof SettingsProjectsProjectKeyRoute | |
| 386 | 427 | } |
| 387 | 428 | |
| 388 | 429 | const SettingsRouteChildren: SettingsRouteChildren = { |
| @@ -392,8 +433,10 @@ const SettingsRouteChildren: SettingsRouteChildren = { · 3 unmodified lines | ||
| 392 | 433 | SettingsDiagnosticsRoute: SettingsDiagnosticsRoute, |
| 393 | 434 | SettingsGeneralRoute: SettingsGeneralRoute, |
| 394 | 435 | SettingsKeybindingsRoute: SettingsKeybindingsRoute, |
| 436 | + SettingsProjectsRoute: SettingsProjectsRoute, | |
| 395 | 437 | SettingsProvidersRoute: SettingsProvidersRoute, |
| 396 | 438 | SettingsSourceControlRoute: SettingsSourceControlRoute, |
| 439 | + SettingsProjectsProjectKeyRoute: SettingsProjectsProjectKeyRoute, | |
| 397 | 440 | } |
| 398 | 441 | |
| 399 | 442 | const SettingsRouteWithChildren = SettingsRoute._addFileChildren( |
| @@ -0,0 +1,11 @@ | ||
| 1 | +import { createFileRoute } from "@tanstack/react-router"; | |
| 2 | + | |
| 3 | +import { ProjectSettingsPanel } from "../components/settings/ProjectSettingsPanel"; | |
| 4 | + | |
| 5 | +function SettingsProjectsRoute() { | |
| 6 | + return <ProjectSettingsPanel selectedProjectKey={null} />; | |
| 7 | +} | |
| 8 | + | |
| 9 | +export const Route = createFileRoute("/settings/projects")({ | |
| 10 | + component: SettingsProjectsRoute, | |
| 11 | +}); | |
| @@ -0,0 +1,12 @@ | ||
| 1 | +import { createFileRoute } from "@tanstack/react-router"; | |
| 2 | + | |
| 3 | +import { ProjectSettingsPanel } from "../components/settings/ProjectSettingsPanel"; | |
| 4 | + | |
| 5 | +function SettingsProjectDetailRoute() { | |
| 6 | + const { projectKey } = Route.useParams(); | |
| 7 | + return <ProjectSettingsPanel selectedProjectKey={projectKey} />; | |
| 8 | +} | |
| 9 | + | |
| 10 | +export const Route = createFileRoute("/settings/projects_/$projectKey")({ | |
| 11 | + component: SettingsProjectDetailRoute, | |
| 12 | +}); | |