| author | |
| committer | |
| log | 288d8e3457f0466da2cbef2eab648331c969b8a7 |
| tree | 8291864bac7c791f5728a4fa3e46a5260d208884 |
| parent | 5208bdeb0db95063091a29f97af3548436c5f291 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>11 files changed, 1609 insertions(+), 756 deletions(-)
apps/web/src/components/CommandPalette.tsx+11| ... | ... | @@ -1505,6 +1505,17 @@ function OpenCommandPaletteDialog(props: { |
| 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}`; |
apps/web/src/components/ProjectScriptsControl.tsx+32-382| ... | ... | @@ -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 { |
| 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({ |
| 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({ |
| 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({ |
| 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({ |
| 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 | } |
apps/web/src/components/Sidebar.tsx+11-365| ... | ... | @@ -28,7 +28,7 @@ import { |
| 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 { |
| 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 { |
| 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 |
| 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"; |
| 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"; |
| 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 { |
| 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"; |
| 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"; |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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 | ); |
apps/web/src/components/projectScriptEditor.tsx created+415| ... | ... | @@ -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 | } |
apps/web/src/components/settings/ProjectSettingsPanel.tsx created+1011| ... | ... | @@ -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 | } |
apps/web/src/components/settings/SettingsSidebarNav.tsx+5-1| ... | ... | @@ -11,6 +11,7 @@ import { |
| 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< |
| 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 }) { |
| 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 |
apps/web/src/components/settings/settingsSearch.ts+22| ... | ... | @@ -2,6 +2,7 @@ export type SettingsPath = |
| 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>> = { |
| 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 = [ |
| 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", |
apps/web/src/hooks/useT3ProjectFileScripts.ts+36-8| ... | ... | @@ -14,6 +14,41 @@ const decodeT3ProjectFile = Schema.decodeExit(T3ProjectFileFromJson); |
| 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( |
| 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 | } |
apps/web/src/routeTree.gen.ts+43| ... | ... | @@ -17,6 +17,7 @@ import { Route as ChatRouteImport } from './routes/_chat' |
| 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 |
| 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({ |
| 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({ |
| 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 { |
| 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 { |
| 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 { |
| 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 { |
| 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 { |
| 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 { |
| 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' { |
| 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' { |
| 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 { |
| 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 = { |
| 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( |
apps/web/src/routes/settings.projects.tsx created+11| ... | ... | @@ -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 | }); |
apps/web/src/routes/settings.projects_.$projectKey.tsx created+12| ... | ... | @@ -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 | }); |