| author | |
| committer | |
| log | 3537d477027565cd0d2c1661ff2e41d8f20ea166 |
| tree | 11cf922eefa567dd5486af4236947b5b6c4c0d15 |
| parent | 9e877f592bad913a5d6e9168becab35a5d78605d |
- add project-scoped script actions in the chat toolbar, including setup-on-worktree-create
- support dynamic `script.{id}.run` keybindings and command resolution
- persist and sync project scripts across web state, contracts, WebSocket API, and server registry
- add backend and frontend tests for script helpers, persistence, and registry behavior25 files changed, 1068 insertions(+), 25 deletions(-)
KEYBINDINGS.md+1| ... | ... | @@ -53,6 +53,7 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged |
| 53 | 53 | - `chat.new`: create a new chat thread preserving the active thread's branch/worktree state |
| 54 | 54 | - `chat.newLocal`: create a new local chat thread for the active project (no worktree context) |
| 55 | 55 | - `editor.openFavorite`: open current project/worktree in the last-used editor |
| 56 | - `script.{id}.run`: run a project script by id (for example `script.test.run`) | |
| 56 | 57 | |
| 57 | 58 | ### Key Syntax |
| 58 | 59 |
apps/server/src/projectRegistry.test.ts created+113| ... | ... | @@ -0,0 +1,113 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import os from "node:os"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 5 | import { afterEach, describe, expect, it } from "vitest"; | |
| 6 | ||
| 7 | import { ProjectRegistry } from "./projectRegistry"; | |
| 8 | ||
| 9 | const tempDirs: string[] = []; | |
| 10 | ||
| 11 | function makeTempDir(prefix: string): string { | |
| 12 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); | |
| 13 | tempDirs.push(dir); | |
| 14 | return dir; | |
| 15 | } | |
| 16 | ||
| 17 | afterEach(() => { | |
| 18 | for (const dir of tempDirs.splice(0, tempDirs.length)) { | |
| 19 | fs.rmSync(dir, { recursive: true, force: true }); | |
| 20 | } | |
| 21 | }); | |
| 22 | ||
| 23 | describe("ProjectRegistry scripts", () => { | |
| 24 | it("stores and reloads project scripts", () => { | |
| 25 | const stateDir = makeTempDir("t3code-project-registry-state-"); | |
| 26 | const projectDir = makeTempDir("t3code-project-registry-project-"); | |
| 27 | const registry = new ProjectRegistry(stateDir); | |
| 28 | ||
| 29 | const created = registry.add({ cwd: projectDir }).project; | |
| 30 | const updated = registry.updateScripts({ | |
| 31 | id: created.id, | |
| 32 | scripts: [ | |
| 33 | { | |
| 34 | id: "run", | |
| 35 | name: "Run", | |
| 36 | command: "bun run dev", | |
| 37 | icon: "play", | |
| 38 | runOnWorktreeCreate: false, | |
| 39 | }, | |
| 40 | { | |
| 41 | id: "setup", | |
| 42 | name: "Setup", | |
| 43 | command: "bun install", | |
| 44 | icon: "configure", | |
| 45 | runOnWorktreeCreate: true, | |
| 46 | }, | |
| 47 | ], | |
| 48 | }); | |
| 49 | ||
| 50 | expect(updated.project.scripts).toHaveLength(2); | |
| 51 | expect(updated.project.scripts[1]?.id).toBe("setup"); | |
| 52 | expect(updated.project.scripts[1]?.runOnWorktreeCreate).toBe(true); | |
| 53 | ||
| 54 | const reloaded = new ProjectRegistry(stateDir); | |
| 55 | const listed = reloaded.list(); | |
| 56 | expect(listed).toHaveLength(1); | |
| 57 | expect(listed[0]?.scripts).toHaveLength(2); | |
| 58 | expect(listed[0]?.scripts[0]?.id).toBe("run"); | |
| 59 | expect(listed[0]?.scripts[1]?.id).toBe("setup"); | |
| 60 | }); | |
| 61 | ||
| 62 | it("deduplicates script ids and allows only one setup script", () => { | |
| 63 | const stateDir = makeTempDir("t3code-project-registry-state-"); | |
| 64 | const projectDir = makeTempDir("t3code-project-registry-project-"); | |
| 65 | const registry = new ProjectRegistry(stateDir); | |
| 66 | const created = registry.add({ cwd: projectDir }).project; | |
| 67 | ||
| 68 | const updated = registry.updateScripts({ | |
| 69 | id: created.id, | |
| 70 | scripts: [ | |
| 71 | { | |
| 72 | id: "setup", | |
| 73 | name: "Setup", | |
| 74 | command: "echo setup", | |
| 75 | icon: "configure", | |
| 76 | runOnWorktreeCreate: true, | |
| 77 | }, | |
| 78 | { | |
| 79 | id: "setup", | |
| 80 | name: "Setup Duplicate", | |
| 81 | command: "echo duplicate", | |
| 82 | icon: "configure", | |
| 83 | runOnWorktreeCreate: true, | |
| 84 | }, | |
| 85 | { | |
| 86 | id: "another", | |
| 87 | name: "Another Setup", | |
| 88 | command: "echo another", | |
| 89 | icon: "play", | |
| 90 | runOnWorktreeCreate: true, | |
| 91 | }, | |
| 92 | ], | |
| 93 | }); | |
| 94 | ||
| 95 | expect(updated.project.scripts).toHaveLength(2); | |
| 96 | expect(updated.project.scripts[0]?.id).toBe("setup"); | |
| 97 | expect(updated.project.scripts[0]?.runOnWorktreeCreate).toBe(true); | |
| 98 | expect(updated.project.scripts[1]?.id).toBe("another"); | |
| 99 | expect(updated.project.scripts[1]?.runOnWorktreeCreate).toBe(false); | |
| 100 | }); | |
| 101 | ||
| 102 | it("throws when updating scripts for an unknown project id", () => { | |
| 103 | const stateDir = makeTempDir("t3code-project-registry-state-"); | |
| 104 | const registry = new ProjectRegistry(stateDir); | |
| 105 | ||
| 106 | expect(() => | |
| 107 | registry.updateScripts({ | |
| 108 | id: "missing-project", | |
| 109 | scripts: [], | |
| 110 | }), | |
| 111 | ).toThrowError("Project not found"); | |
| 112 | }); | |
| 113 | }); |
apps/server/src/projectRegistry.ts+97-3| ... | ... | @@ -8,11 +8,30 @@ import { |
| 8 | 8 | type ProjectListResult, |
| 9 | 9 | type ProjectRecord, |
| 10 | 10 | type ProjectRemoveInput, |
| 11 | type ProjectScript, | |
| 12 | type ProjectUpdateScriptsInput, | |
| 13 | type ProjectUpdateScriptsResult, | |
| 11 | 14 | projectAddInputSchema, |
| 12 | 15 | projectRecordSchema, |
| 13 | 16 | projectRemoveInputSchema, |
| 17 | projectScriptsSchema, | |
| 18 | projectUpdateScriptsInputSchema, | |
| 14 | 19 | } from "@t3tools/contracts"; |
| 15 | 20 | |
| 21 | function cloneScripts(scripts: readonly ProjectScript[]): ProjectScript[] { | |
| 22 | const cloned: ProjectScript[] = []; | |
| 23 | for (const script of scripts) { | |
| 24 | cloned.push({ | |
| 25 | id: script.id, | |
| 26 | name: script.name, | |
| 27 | command: script.command, | |
| 28 | icon: script.icon, | |
| 29 | runOnWorktreeCreate: script.runOnWorktreeCreate, | |
| 30 | }); | |
| 31 | } | |
| 32 | return cloned; | |
| 33 | } | |
| 34 | ||
| 16 | 35 | function normalizeCwd(rawCwd: string): string { |
| 17 | 36 | const resolved = path.resolve(rawCwd.trim()); |
| 18 | 37 | const normalized = path.normalize(resolved); |
| ... | ... | @@ -48,7 +67,10 @@ export class ProjectRegistry { |
| 48 | 67 | } |
| 49 | 68 | |
| 50 | 69 | list(): ProjectListResult { |
| 51 | return this.projects.map((project) => ({ ...project })); | |
| 70 | return this.projects.map((project) => ({ | |
| 71 | ...project, | |
| 72 | scripts: cloneScripts(project.scripts), | |
| 73 | })); | |
| 52 | 74 | } |
| 53 | 75 | |
| 54 | 76 | add(raw: ProjectAddInput): ProjectAddResult { |
| ... | ... | @@ -60,7 +82,13 @@ export class ProjectRegistry { |
| 60 | 82 | |
| 61 | 83 | const existing = this.projects.find((project) => normalizeCwd(project.cwd) === normalizedCwd); |
| 62 | 84 | if (existing) { |
| 63 | return { project: { ...existing }, created: false }; | |
| 85 | return { | |
| 86 | project: { | |
| 87 | ...existing, | |
| 88 | scripts: cloneScripts(existing.scripts), | |
| 89 | }, | |
| 90 | created: false, | |
| 91 | }; | |
| 64 | 92 | } |
| 65 | 93 | |
| 66 | 94 | const now = new Date().toISOString(); |
| ... | ... | @@ -68,13 +96,20 @@ export class ProjectRegistry { |
| 68 | 96 | id: randomUUID(), |
| 69 | 97 | cwd: normalizedCwd, |
| 70 | 98 | name: inferProjectName(normalizedCwd), |
| 99 | scripts: [], | |
| 71 | 100 | createdAt: now, |
| 72 | 101 | updatedAt: now, |
| 73 | 102 | }; |
| 74 | 103 | |
| 75 | 104 | this.projects = [project, ...this.projects]; |
| 76 | 105 | this.persist(); |
| 77 | return { project: { ...project }, created: true }; | |
| 106 | return { | |
| 107 | project: { | |
| 108 | ...project, | |
| 109 | scripts: cloneScripts(project.scripts), | |
| 110 | }, | |
| 111 | created: true, | |
| 112 | }; | |
| 78 | 113 | } |
| 79 | 114 | |
| 80 | 115 | remove(raw: ProjectRemoveInput): void { |
| ... | ... | @@ -88,6 +123,31 @@ export class ProjectRegistry { |
| 88 | 123 | this.persist(); |
| 89 | 124 | } |
| 90 | 125 | |
| 126 | updateScripts(raw: ProjectUpdateScriptsInput): ProjectUpdateScriptsResult { | |
| 127 | const input = projectUpdateScriptsInputSchema.parse(raw); | |
| 128 | const index = this.projects.findIndex((project) => project.id === input.id); | |
| 129 | if (index < 0) { | |
| 130 | throw new Error(`Project not found: ${input.id}`); | |
| 131 | } | |
| 132 | ||
| 133 | const nextScripts = dedupeScriptsById(projectScriptsSchema.parse(input.scripts)); | |
| 134 | const nextUpdatedAt = new Date().toISOString(); | |
| 135 | const nextProject: ProjectRecord = { | |
| 136 | ...this.projects[index]!, | |
| 137 | scripts: nextScripts, | |
| 138 | updatedAt: nextUpdatedAt, | |
| 139 | }; | |
| 140 | ||
| 141 | const nextProjects = [...this.projects]; | |
| 142 | nextProjects[index] = nextProject; | |
| 143 | this.projects = nextProjects; | |
| 144 | this.persist(); | |
| 145 | ||
| 146 | return { | |
| 147 | project: { ...nextProject, scripts: cloneScripts(nextProject.scripts) }, | |
| 148 | }; | |
| 149 | } | |
| 150 | ||
| 91 | 151 | private loadFromDisk(): ProjectRecord[] { |
| 92 | 152 | const loaded = this.readPersistedProjects(); |
| 93 | 153 | const deduped = new Map<string, ProjectRecord>(); |
| ... | ... | @@ -107,6 +167,7 @@ export class ProjectRegistry { |
| 107 | 167 | cwd: normalizedCwd, |
| 108 | 168 | name: |
| 109 | 169 | project.name.trim().length > 0 ? project.name.trim() : inferProjectName(normalizedCwd), |
| 170 | scripts: dedupeScriptsById(project.scripts), | |
| 110 | 171 | }; |
| 111 | 172 | deduped.set(normalizedCwd, normalizedProject); |
| 112 | 173 | normalizedProjects.push(normalizedProject); |
| ... | ... | @@ -121,6 +182,7 @@ export class ProjectRegistry { |
| 121 | 182 | source.id !== project.id || |
| 122 | 183 | source.cwd !== project.cwd || |
| 123 | 184 | source.name !== project.name || |
| 185 | JSON.stringify(source.scripts) !== JSON.stringify(project.scripts) || | |
| 124 | 186 | source.createdAt !== project.createdAt || |
| 125 | 187 | source.updatedAt !== project.updatedAt |
| 126 | 188 | ); |
| ... | ... | @@ -170,3 +232,35 @@ export class ProjectRegistry { |
| 170 | 232 | fs.renameSync(tempFile, this.filePath); |
| 171 | 233 | } |
| 172 | 234 | } |
| 235 | ||
| 236 | function dedupeScriptsById(scripts: readonly ProjectScript[]): ProjectScript[] { | |
| 237 | const seenIds = new Set<string>(); | |
| 238 | const deduped: ProjectScript[] = []; | |
| 239 | for (const script of scripts) { | |
| 240 | if (seenIds.has(script.id)) continue; | |
| 241 | seenIds.add(script.id); | |
| 242 | deduped.push({ | |
| 243 | id: script.id, | |
| 244 | name: script.name, | |
| 245 | command: script.command, | |
| 246 | icon: script.icon, | |
| 247 | runOnWorktreeCreate: script.runOnWorktreeCreate, | |
| 248 | }); | |
| 249 | } | |
| 250 | ||
| 251 | let setupAssigned = false; | |
| 252 | const normalized: ProjectScript[] = []; | |
| 253 | for (const script of deduped) { | |
| 254 | if (!script.runOnWorktreeCreate) { | |
| 255 | normalized.push(script); | |
| 256 | continue; | |
| 257 | } | |
| 258 | if (!setupAssigned) { | |
| 259 | setupAssigned = true; | |
| 260 | normalized.push(script); | |
| 261 | continue; | |
| 262 | } | |
| 263 | normalized.push({ ...script, runOnWorktreeCreate: false }); | |
| 264 | } | |
| 265 | return normalized; | |
| 266 | } |
apps/server/src/wsServer.test.ts+36| ... | ... | @@ -756,6 +756,42 @@ describe("WebSocket Server", () => { |
| 756 | 756 | expect(projectId).toBeTruthy(); |
| 757 | 757 | if (!projectId) return; |
| 758 | 758 | |
| 759 | const updatedScripts = await sendRequest(ws, WS_METHODS.projectsUpdateScripts, { | |
| 760 | id: projectId, | |
| 761 | scripts: [ | |
| 762 | { | |
| 763 | id: "setup", | |
| 764 | name: "Setup", | |
| 765 | command: "bun install", | |
| 766 | icon: "configure", | |
| 767 | runOnWorktreeCreate: true, | |
| 768 | }, | |
| 769 | ], | |
| 770 | }); | |
| 771 | expect(updatedScripts.error).toBeUndefined(); | |
| 772 | const scriptPayload = ( | |
| 773 | updatedScripts.result as { | |
| 774 | project: { | |
| 775 | scripts: Array<{ | |
| 776 | id: string; | |
| 777 | name: string; | |
| 778 | command: string; | |
| 779 | icon: string; | |
| 780 | runOnWorktreeCreate: boolean; | |
| 781 | }>; | |
| 782 | }; | |
| 783 | } | |
| 784 | ).project.scripts; | |
| 785 | expect(scriptPayload).toEqual([ | |
| 786 | { | |
| 787 | id: "setup", | |
| 788 | name: "Setup", | |
| 789 | command: "bun install", | |
| 790 | icon: "configure", | |
| 791 | runOnWorktreeCreate: true, | |
| 792 | }, | |
| 793 | ]); | |
| 794 | ||
| 759 | 795 | const removed = await sendRequest(ws, WS_METHODS.projectsRemove, { |
| 760 | 796 | id: projectId, |
| 761 | 797 | }); |
apps/server/src/wsServer.ts+2| ... | ... | @@ -322,6 +322,8 @@ export function createServer(options: ServerOptions) { |
| 322 | 322 | |
| 323 | 323 | case WS_METHODS.projectsSearchEntries: |
| 324 | 324 | return searchWorkspaceEntries(request.params as never); |
| 325 | case WS_METHODS.projectsUpdateScripts: | |
| 326 | return projectRegistry.updateScripts(request.params as never); | |
| 325 | 327 | |
| 326 | 328 | case WS_METHODS.shellOpenInEditor: { |
| 327 | 329 | const params = request.params as { |
apps/web/src/App.tsx+2| ... | ... | @@ -85,6 +85,7 @@ function AutoProjectBootstrap() { |
| 85 | 85 | cwd: payload.cwd, |
| 86 | 86 | model: DEFAULT_MODEL, |
| 87 | 87 | expanded: true, |
| 88 | scripts: [], | |
| 88 | 89 | }, |
| 89 | 90 | }); |
| 90 | 91 | dispatch({ |
| ... | ... | @@ -146,6 +147,7 @@ function DesktopProjectBootstrap() { |
| 146 | 147 | cwd: project.cwd, |
| 147 | 148 | model: DEFAULT_MODEL, |
| 148 | 149 | expanded: true, |
| 150 | scripts: project.scripts, | |
| 149 | 151 | })), |
| 150 | 152 | }); |
| 151 | 153 | bootstrappedRef.current = true; |
apps/web/src/components/ChatView.tsx+184-17| ... | ... | @@ -1,9 +1,10 @@ |
| 1 | 1 | import { |
| 2 | 2 | EDITORS, |
| 3 | 3 | type EditorId, |
| 4 | type NativeApi, | |
| 4 | 5 | type ProjectEntry, |
| 6 | type ProjectScript, | |
| 5 | 7 | ModelSlug, |
| 6 | type NativeApi, | |
| 7 | 8 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, |
| 8 | 9 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 9 | 10 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, |
| ... | ... | @@ -66,10 +67,7 @@ import BranchToolbar from "./BranchToolbar"; |
| 66 | 67 | import GitActionsControl from "./GitActionsControl"; |
| 67 | 68 | import { |
| 68 | 69 | isOpenFavoriteEditorShortcut, |
| 69 | isTerminalCloseShortcut, | |
| 70 | isTerminalNewShortcut, | |
| 71 | isTerminalSplitShortcut, | |
| 72 | isTerminalToggleShortcut, | |
| 70 | resolveShortcutCommand, | |
| 73 | 71 | shortcutLabelForCommand, |
| 74 | 72 | } from "../keybindings"; |
| 75 | 73 | import ChatMarkdown from "./ChatMarkdown"; |
| ... | ... | @@ -97,6 +95,13 @@ import { CursorIcon, Icon } from "./Icons"; |
| 97 | 95 | import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; |
| 98 | 96 | import { Badge } from "./ui/badge"; |
| 99 | 97 | import { Command, CommandInput, CommandItem, CommandList } from "./ui/command"; |
| 98 | import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl"; | |
| 99 | import { | |
| 100 | injectEnvIntoShellCommand, | |
| 101 | nextProjectScriptId, | |
| 102 | projectScriptIdFromCommand, | |
| 103 | setupProjectScript, | |
| 104 | } from "~/projectScripts"; | |
| 100 | 105 | |
| 101 | 106 | function formatMessageMeta(createdAt: string, duration: string | null): string { |
| 102 | 107 | if (!duration) return formatTimestamp(createdAt); |
| ... | ... | @@ -117,6 +122,8 @@ const SEARCHABLE_MODEL_OPTIONS = MODEL_OPTIONS.map(({ slug, name }) => ({ |
| 117 | 122 | searchSlug: slug.toLowerCase(), |
| 118 | 123 | searchName: name.toLowerCase(), |
| 119 | 124 | })); |
| 125 | const SCRIPT_TERMINAL_COLS = 120; | |
| 126 | const SCRIPT_TERMINAL_ROWS = 30; | |
| 120 | 127 | |
| 121 | 128 | function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { |
| 122 | 129 | if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50"; |
| ... | ... | @@ -618,6 +625,125 @@ export default function ChatView() { |
| 618 | 625 | }, |
| 619 | 626 | [activeThread?.terminalIds.length, activeThreadId, api, dispatch], |
| 620 | 627 | ); |
| 628 | const runProjectScript = useCallback( | |
| 629 | async ( | |
| 630 | script: ProjectScript, | |
| 631 | options?: { | |
| 632 | cwd?: string; | |
| 633 | env?: Record<string, string>; | |
| 634 | preferNewTerminal?: boolean; | |
| 635 | }, | |
| 636 | ) => { | |
| 637 | if (!api || !activeThreadId || !activeProject || !activeThread) return; | |
| 638 | const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd; | |
| 639 | const baseTerminalId = activeThread.activeTerminalId; | |
| 640 | const isBaseTerminalBusy = activeThread.runningTerminalIds.includes(baseTerminalId); | |
| 641 | const shouldCreateNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy; | |
| 642 | const targetTerminalId = shouldCreateNewTerminal | |
| 643 | ? `terminal-${crypto.randomUUID()}` | |
| 644 | : baseTerminalId; | |
| 645 | ||
| 646 | dispatch({ | |
| 647 | type: "SET_THREAD_TERMINAL_OPEN", | |
| 648 | threadId: activeThreadId, | |
| 649 | open: true, | |
| 650 | }); | |
| 651 | if (shouldCreateNewTerminal) { | |
| 652 | dispatch({ | |
| 653 | type: "NEW_THREAD_TERMINAL", | |
| 654 | threadId: activeThreadId, | |
| 655 | terminalId: targetTerminalId, | |
| 656 | }); | |
| 657 | } else { | |
| 658 | dispatch({ | |
| 659 | type: "SET_THREAD_ACTIVE_TERMINAL", | |
| 660 | threadId: activeThreadId, | |
| 661 | terminalId: targetTerminalId, | |
| 662 | }); | |
| 663 | } | |
| 664 | setTerminalFocusRequestId((value) => value + 1); | |
| 665 | ||
| 666 | const scriptEnv = | |
| 667 | options?.env ?? | |
| 668 | (script.runOnWorktreeCreate ? { T3CODE_PROJECT_ROOT: activeProject.cwd } : undefined); | |
| 669 | const resolvedCommand = scriptEnv | |
| 670 | ? injectEnvIntoShellCommand(script.command, scriptEnv) | |
| 671 | : script.command; | |
| 672 | ||
| 673 | try { | |
| 674 | await api.terminal.open({ | |
| 675 | threadId: activeThreadId, | |
| 676 | terminalId: targetTerminalId, | |
| 677 | cwd: targetCwd, | |
| 678 | cols: SCRIPT_TERMINAL_COLS, | |
| 679 | rows: SCRIPT_TERMINAL_ROWS, | |
| 680 | }); | |
| 681 | await api.terminal.write({ | |
| 682 | threadId: activeThreadId, | |
| 683 | terminalId: targetTerminalId, | |
| 684 | data: `${resolvedCommand}\n`, | |
| 685 | }); | |
| 686 | } catch (error) { | |
| 687 | dispatch({ | |
| 688 | type: "SET_ERROR", | |
| 689 | threadId: activeThreadId, | |
| 690 | error: error instanceof Error ? error.message : `Failed to run script "${script.name}".`, | |
| 691 | }); | |
| 692 | } | |
| 693 | }, | |
| 694 | [activeProject, activeThread, activeThreadId, api, dispatch, gitCwd], | |
| 695 | ); | |
| 696 | const saveProjectScript = useCallback( | |
| 697 | async (input: NewProjectScriptInput) => { | |
| 698 | if (!activeProject) return; | |
| 699 | const nextId = nextProjectScriptId( | |
| 700 | input.name, | |
| 701 | activeProject.scripts.map((script) => script.id), | |
| 702 | ); | |
| 703 | const nextScript: ProjectScript = { | |
| 704 | id: nextId, | |
| 705 | name: input.name, | |
| 706 | command: input.command, | |
| 707 | icon: input.icon, | |
| 708 | runOnWorktreeCreate: input.runOnWorktreeCreate, | |
| 709 | }; | |
| 710 | const nextScripts = input.runOnWorktreeCreate | |
| 711 | ? [ | |
| 712 | ...activeProject.scripts.map((script) => | |
| 713 | script.runOnWorktreeCreate ? { ...script, runOnWorktreeCreate: false } : script, | |
| 714 | ), | |
| 715 | nextScript, | |
| 716 | ] | |
| 717 | : [...activeProject.scripts, nextScript]; | |
| 718 | ||
| 719 | dispatch({ | |
| 720 | type: "SET_PROJECT_SCRIPTS", | |
| 721 | projectId: activeProject.id, | |
| 722 | scripts: nextScripts, | |
| 723 | }); | |
| 724 | ||
| 725 | if (!isElectron || !api) return; | |
| 726 | try { | |
| 727 | const updated = await api.projects.updateScripts({ | |
| 728 | id: activeProject.id, | |
| 729 | scripts: nextScripts, | |
| 730 | }); | |
| 731 | dispatch({ | |
| 732 | type: "SET_PROJECT_SCRIPTS", | |
| 733 | projectId: updated.project.id, | |
| 734 | scripts: updated.project.scripts, | |
| 735 | }); | |
| 736 | } catch (error) { | |
| 737 | dispatch({ | |
| 738 | type: "SET_PROJECT_SCRIPTS", | |
| 739 | projectId: activeProject.id, | |
| 740 | scripts: activeProject.scripts, | |
| 741 | }); | |
| 742 | throw error; | |
| 743 | } | |
| 744 | }, | |
| 745 | [activeProject, api, dispatch], | |
| 746 | ); | |
| 621 | 747 | |
| 622 | 748 | const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => { |
| 623 | 749 | if (mode === state.runtimeMode) return; |
| ... | ... | @@ -793,14 +919,17 @@ export default function ChatView() { |
| 793 | 919 | terminalOpen: Boolean(activeThread?.terminalOpen), |
| 794 | 920 | }; |
| 795 | 921 | |
| 796 | if (isTerminalToggleShortcut(event, keybindings, { context: shortcutContext })) { | |
| 922 | const command = resolveShortcutCommand(event, keybindings, { context: shortcutContext }); | |
| 923 | if (!command) return; | |
| 924 | ||
| 925 | if (command === "terminal.toggle") { | |
| 797 | 926 | event.preventDefault(); |
| 798 | 927 | event.stopPropagation(); |
| 799 | 928 | toggleTerminalVisibility(); |
| 800 | 929 | return; |
| 801 | 930 | } |
| 802 | 931 | |
| 803 | if (isTerminalSplitShortcut(event, keybindings, { context: shortcutContext })) { | |
| 932 | if (command === "terminal.split") { | |
| 804 | 933 | event.preventDefault(); |
| 805 | 934 | event.stopPropagation(); |
| 806 | 935 | if (!activeThread?.terminalOpen) { |
| ... | ... | @@ -814,7 +943,7 @@ export default function ChatView() { |
| 814 | 943 | return; |
| 815 | 944 | } |
| 816 | 945 | |
| 817 | if (isTerminalCloseShortcut(event, keybindings, { context: shortcutContext })) { | |
| 946 | if (command === "terminal.close") { | |
| 818 | 947 | event.preventDefault(); |
| 819 | 948 | event.stopPropagation(); |
| 820 | 949 | if (!activeThread?.terminalOpen) return; |
| ... | ... | @@ -822,27 +951,39 @@ export default function ChatView() { |
| 822 | 951 | return; |
| 823 | 952 | } |
| 824 | 953 | |
| 825 | if (!isTerminalNewShortcut(event, keybindings, { context: shortcutContext })) return; | |
| 954 | if (command === "terminal.new") { | |
| 955 | event.preventDefault(); | |
| 956 | event.stopPropagation(); | |
| 957 | if (!activeThread?.terminalOpen) { | |
| 958 | dispatch({ | |
| 959 | type: "SET_THREAD_TERMINAL_OPEN", | |
| 960 | threadId: activeThreadId, | |
| 961 | open: true, | |
| 962 | }); | |
| 963 | } | |
| 964 | createNewTerminal(); | |
| 965 | return; | |
| 966 | } | |
| 967 | ||
| 968 | const scriptId = projectScriptIdFromCommand(command); | |
| 969 | if (!scriptId || !activeProject) return; | |
| 970 | const script = activeProject.scripts.find((entry) => entry.id === scriptId); | |
| 971 | if (!script) return; | |
| 826 | 972 | event.preventDefault(); |
| 827 | 973 | event.stopPropagation(); |
| 828 | if (!activeThread?.terminalOpen) { | |
| 829 | dispatch({ | |
| 830 | type: "SET_THREAD_TERMINAL_OPEN", | |
| 831 | threadId: activeThreadId, | |
| 832 | open: true, | |
| 833 | }); | |
| 834 | } | |
| 835 | createNewTerminal(); | |
| 974 | void runProjectScript(script); | |
| 836 | 975 | }; |
| 837 | 976 | window.addEventListener("keydown", handler); |
| 838 | 977 | return () => window.removeEventListener("keydown", handler); |
| 839 | 978 | }, [ |
| 979 | activeProject, | |
| 840 | 980 | activeThread?.terminalOpen, |
| 841 | 981 | activeThread?.activeTerminalId, |
| 842 | 982 | activeThreadId, |
| 843 | 983 | closeTerminal, |
| 844 | 984 | createNewTerminal, |
| 845 | 985 | dispatch, |
| 986 | runProjectScript, | |
| 846 | 987 | splitTerminal, |
| 847 | 988 | keybindings, |
| 848 | 989 | toggleTerminalVisibility, |
| ... | ... | @@ -1047,6 +1188,13 @@ export default function ChatView() { |
| 1047 | 1188 | branch: result.worktree.branch, |
| 1048 | 1189 | worktreePath: result.worktree.path, |
| 1049 | 1190 | }); |
| 1191 | const setupScript = setupProjectScript(activeProject.scripts); | |
| 1192 | if (setupScript) { | |
| 1193 | void runProjectScript(setupScript, { | |
| 1194 | cwd: result.worktree.path, | |
| 1195 | env: { T3CODE_PROJECT_ROOT: activeProject.cwd }, | |
| 1196 | }); | |
| 1197 | } | |
| 1050 | 1198 | } catch (err) { |
| 1051 | 1199 | dispatch({ |
| 1052 | 1200 | type: "SET_ERROR", |
| ... | ... | @@ -1325,10 +1473,15 @@ export default function ChatView() { |
| 1325 | 1473 | <ChatHeader |
| 1326 | 1474 | activeThreadTitle={activeThread.title} |
| 1327 | 1475 | activeProjectName={activeProject?.name} |
| 1476 | activeProjectScripts={activeProject?.scripts} | |
| 1328 | 1477 | keybindings={keybindings} |
| 1329 | 1478 | api={api} |
| 1330 | 1479 | gitCwd={gitCwd} |
| 1331 | 1480 | diffOpen={state.diffOpen} |
| 1481 | onRunProjectScript={(script) => { | |
| 1482 | void runProjectScript(script); | |
| 1483 | }} | |
| 1484 | onAddProjectScript={saveProjectScript} | |
| 1332 | 1485 | onToggleDiff={onToggleDiff} |
| 1333 | 1486 | /> |
| 1334 | 1487 | </header> |
| ... | ... | @@ -1652,20 +1805,26 @@ export default function ChatView() { |
| 1652 | 1805 | interface ChatHeaderProps { |
| 1653 | 1806 | activeThreadTitle: string; |
| 1654 | 1807 | activeProjectName: string | undefined; |
| 1808 | activeProjectScripts: ProjectScript[] | undefined; | |
| 1655 | 1809 | keybindings: ResolvedKeybindingsConfig; |
| 1656 | 1810 | api: NativeApi | undefined; |
| 1657 | 1811 | gitCwd: string | null; |
| 1658 | 1812 | diffOpen: boolean; |
| 1813 | onRunProjectScript: (script: ProjectScript) => void; | |
| 1814 | onAddProjectScript: (input: NewProjectScriptInput) => Promise<void>; | |
| 1659 | 1815 | onToggleDiff: () => void; |
| 1660 | 1816 | } |
| 1661 | 1817 | |
| 1662 | 1818 | const ChatHeader = memo(function ChatHeader({ |
| 1663 | 1819 | activeThreadTitle, |
| 1664 | 1820 | activeProjectName, |
| 1821 | activeProjectScripts, | |
| 1665 | 1822 | keybindings, |
| 1666 | 1823 | api, |
| 1667 | 1824 | gitCwd, |
| 1668 | 1825 | diffOpen, |
| 1826 | onRunProjectScript, | |
| 1827 | onAddProjectScript, | |
| 1669 | 1828 | onToggleDiff, |
| 1670 | 1829 | }: ChatHeaderProps) { |
| 1671 | 1830 | return ( |
| ... | ... | @@ -1677,6 +1836,14 @@ const ChatHeader = memo(function ChatHeader({ |
| 1677 | 1836 | {activeProjectName && <Badge variant="outline">{activeProjectName}</Badge>} |
| 1678 | 1837 | </div> |
| 1679 | 1838 | <div className="shrink-0 flex items-center gap-3"> |
| 1839 | {activeProjectScripts && ( | |
| 1840 | <ProjectScriptsControl | |
| 1841 | scripts={activeProjectScripts} | |
| 1842 | keybindings={keybindings} | |
| 1843 | onRunScript={onRunProjectScript} | |
| 1844 | onAddScript={onAddProjectScript} | |
| 1845 | /> | |
| 1846 | )} | |
| 1680 | 1847 | {activeProjectName && <OpenInPicker keybindings={keybindings} />} |
| 1681 | 1848 | {activeProjectName && <GitActionsControl api={api} gitCwd={gitCwd} />} |
| 1682 | 1849 | <Button |
apps/web/src/components/ProjectScriptsControl.tsx created+259| ... | ... | @@ -0,0 +1,259 @@ |
| 1 | import type { | |
| 2 | ProjectScript, | |
| 3 | ProjectScriptIcon, | |
| 4 | ResolvedKeybindingsConfig, | |
| 5 | } from "@t3tools/contracts"; | |
| 6 | import { | |
| 7 | ChevronDownIcon, | |
| 8 | FlaskConicalIcon, | |
| 9 | HammerIcon, | |
| 10 | ListChecksIcon, | |
| 11 | PlayIcon, | |
| 12 | PlusIcon, | |
| 13 | WrenchIcon, | |
| 14 | } from "lucide-react"; | |
| 15 | import { type FormEvent, useMemo, useState } from "react"; | |
| 16 | ||
| 17 | import { | |
| 18 | commandForProjectScript, | |
| 19 | nextProjectScriptId, | |
| 20 | primaryProjectScript, | |
| 21 | } from "~/projectScripts"; | |
| 22 | import { shortcutLabelForCommand } from "~/keybindings"; | |
| 23 | import { Button } from "./ui/button"; | |
| 24 | import { | |
| 25 | Dialog, | |
| 26 | DialogDescription, | |
| 27 | DialogFooter, | |
| 28 | DialogHeader, | |
| 29 | DialogPanel, | |
| 30 | DialogPopup, | |
| 31 | DialogTitle, | |
| 32 | } from "./ui/dialog"; | |
| 33 | import { Group, GroupSeparator } from "./ui/group"; | |
| 34 | import { Input } from "./ui/input"; | |
| 35 | import { Label } from "./ui/label"; | |
| 36 | import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu"; | |
| 37 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; | |
| 38 | import { Switch } from "./ui/switch"; | |
| 39 | import { Textarea } from "./ui/textarea"; | |
| 40 | ||
| 41 | const SCRIPT_ICONS: Array<{ id: ProjectScriptIcon; label: string }> = [ | |
| 42 | { id: "play", label: "Play" }, | |
| 43 | { id: "test", label: "Test" }, | |
| 44 | { id: "lint", label: "Lint" }, | |
| 45 | { id: "configure", label: "Configure" }, | |
| 46 | { id: "build", label: "Build" }, | |
| 47 | ]; | |
| 48 | ||
| 49 | function ScriptIcon({ | |
| 50 | icon, | |
| 51 | className = "size-3.5", | |
| 52 | }: { | |
| 53 | icon: ProjectScriptIcon; | |
| 54 | className?: string; | |
| 55 | }) { | |
| 56 | if (icon === "test") return <FlaskConicalIcon className={className} />; | |
| 57 | if (icon === "lint") return <ListChecksIcon className={className} />; | |
| 58 | if (icon === "configure") return <WrenchIcon className={className} />; | |
| 59 | if (icon === "build") return <HammerIcon className={className} />; | |
| 60 | return <PlayIcon className={className} />; | |
| 61 | } | |
| 62 | ||
| 63 | export interface NewProjectScriptInput { | |
| 64 | name: string; | |
| 65 | command: string; | |
| 66 | icon: ProjectScriptIcon; | |
| 67 | runOnWorktreeCreate: boolean; | |
| 68 | } | |
| 69 | ||
| 70 | interface ProjectScriptsControlProps { | |
| 71 | scripts: ProjectScript[]; | |
| 72 | keybindings: ResolvedKeybindingsConfig; | |
| 73 | onRunScript: (script: ProjectScript) => void; | |
| 74 | onAddScript: (input: NewProjectScriptInput) => Promise<void> | void; | |
| 75 | } | |
| 76 | ||
| 77 | export default function ProjectScriptsControl({ | |
| 78 | scripts, | |
| 79 | keybindings, | |
| 80 | onRunScript, | |
| 81 | onAddScript, | |
| 82 | }: ProjectScriptsControlProps) { | |
| 83 | const [dialogOpen, setDialogOpen] = useState(false); | |
| 84 | const [name, setName] = useState(""); | |
| 85 | const [command, setCommand] = useState(""); | |
| 86 | const [icon, setIcon] = useState<ProjectScriptIcon>("play"); | |
| 87 | const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false); | |
| 88 | const [validationError, setValidationError] = useState<string | null>(null); | |
| 89 | ||
| 90 | const primaryScript = useMemo(() => primaryProjectScript(scripts), [scripts]); | |
| 91 | const keybindingCommandPreview = useMemo( | |
| 92 | () => | |
| 93 | commandForProjectScript( | |
| 94 | nextProjectScriptId( | |
| 95 | name.length > 0 ? name : "script", | |
| 96 | scripts.map((script) => script.id), | |
| 97 | ), | |
| 98 | ), | |
| 99 | [name, scripts], | |
| 100 | ); | |
| 101 | ||
| 102 | const submitAddScript = async (event: FormEvent) => { | |
| 103 | event.preventDefault(); | |
| 104 | const trimmedName = name.trim(); | |
| 105 | const trimmedCommand = command.trim(); | |
| 106 | if (trimmedName.length === 0) { | |
| 107 | setValidationError("Name is required."); | |
| 108 | return; | |
| 109 | } | |
| 110 | if (trimmedCommand.length === 0) { | |
| 111 | setValidationError("Command is required."); | |
| 112 | return; | |
| 113 | } | |
| 114 | ||
| 115 | setValidationError(null); | |
| 116 | try { | |
| 117 | await onAddScript({ | |
| 118 | name: trimmedName, | |
| 119 | command: trimmedCommand, | |
| 120 | icon, | |
| 121 | runOnWorktreeCreate, | |
| 122 | }); | |
| 123 | setDialogOpen(false); | |
| 124 | setName(""); | |
| 125 | setCommand(""); | |
| 126 | setIcon("play"); | |
| 127 | setRunOnWorktreeCreate(false); | |
| 128 | } catch (error) { | |
| 129 | setValidationError(error instanceof Error ? error.message : "Failed to save action."); | |
| 130 | } | |
| 131 | }; | |
| 132 | ||
| 133 | const openDialog = () => { | |
| 134 | setValidationError(null); | |
| 135 | setDialogOpen(true); | |
| 136 | }; | |
| 137 | ||
| 138 | return ( | |
| 139 | <> | |
| 140 | {primaryScript ? ( | |
| 141 | <Group aria-label="Project scripts"> | |
| 142 | <Button | |
| 143 | size="xs" | |
| 144 | variant="outline" | |
| 145 | onClick={() => onRunScript(primaryScript)} | |
| 146 | title={`Run ${primaryScript.name}`} | |
| 147 | > | |
| 148 | <ScriptIcon icon={primaryScript.icon} /> | |
| 149 | <span>{primaryScript.name}</span> | |
| 150 | </Button> | |
| 151 | <GroupSeparator /> | |
| 152 | <Menu> | |
| 153 | <MenuTrigger | |
| 154 | render={<Button size="icon-xs" variant="outline" aria-label="Script actions" />} | |
| 155 | > | |
| 156 | <ChevronDownIcon className="size-4" /> | |
| 157 | </MenuTrigger> | |
| 158 | <MenuPopup align="end"> | |
| 159 | {scripts.map((script) => { | |
| 160 | const shortcutLabel = shortcutLabelForCommand( | |
| 161 | keybindings, | |
| 162 | commandForProjectScript(script.id), | |
| 163 | ); | |
| 164 | return ( | |
| 165 | <MenuItem key={script.id} onClick={() => onRunScript(script)}> | |
| 166 | <ScriptIcon icon={script.icon} className="size-4" /> | |
| 167 | {script.runOnWorktreeCreate ? `${script.name} (Setup)` : script.name} | |
| 168 | {shortcutLabel && <MenuShortcut>{shortcutLabel}</MenuShortcut>} | |
| 169 | </MenuItem> | |
| 170 | ); | |
| 171 | })} | |
| 172 | <MenuItem onClick={openDialog}> | |
| 173 | <PlusIcon className="size-4" /> | |
| 174 | Add action | |
| 175 | </MenuItem> | |
| 176 | </MenuPopup> | |
| 177 | </Menu> | |
| 178 | </Group> | |
| 179 | ) : ( | |
| 180 | <Button size="xs" variant="outline" onClick={openDialog}> | |
| 181 | <PlusIcon className="size-3.5" /> | |
| 182 | Add action | |
| 183 | </Button> | |
| 184 | )} | |
| 185 | ||
| 186 | <Dialog onOpenChange={setDialogOpen} open={dialogOpen}> | |
| 187 | <DialogPopup> | |
| 188 | <form onSubmit={submitAddScript}> | |
| 189 | <DialogHeader> | |
| 190 | <DialogTitle>Add Action</DialogTitle> | |
| 191 | <DialogDescription> | |
| 192 | Actions are project-scoped commands you can run from the top bar or keybindings. | |
| 193 | </DialogDescription> | |
| 194 | </DialogHeader> | |
| 195 | <DialogPanel className="space-y-4"> | |
| 196 | <div className="space-y-1.5"> | |
| 197 | <Label htmlFor="script-name">Name</Label> | |
| 198 | <Input | |
| 199 | id="script-name" | |
| 200 | placeholder="Test" | |
| 201 | value={name} | |
| 202 | onChange={(event) => setName(event.target.value)} | |
| 203 | /> | |
| 204 | <p className="text-xs text-muted-foreground"> | |
| 205 | Keybinding command: <code>{keybindingCommandPreview}</code> | |
| 206 | </p> | |
| 207 | </div> | |
| 208 | <div className="space-y-1.5"> | |
| 209 | <Label htmlFor="script-command">Command</Label> | |
| 210 | <Textarea | |
| 211 | id="script-command" | |
| 212 | placeholder="bun test" | |
| 213 | value={command} | |
| 214 | onChange={(event) => setCommand(event.target.value)} | |
| 215 | /> | |
| 216 | </div> | |
| 217 | <div className="space-y-1.5"> | |
| 218 | <Label>Icon</Label> | |
| 219 | <Select | |
| 220 | items={SCRIPT_ICONS.map((entry) => ({ label: entry.label, value: entry.id }))} | |
| 221 | value={icon} | |
| 222 | onValueChange={(value) => { | |
| 223 | if (!value) return; | |
| 224 | setIcon(value as ProjectScriptIcon); | |
| 225 | }} | |
| 226 | > | |
| 227 | <SelectTrigger> | |
| 228 | <SelectValue /> | |
| 229 | </SelectTrigger> | |
| 230 | <SelectPopup> | |
| 231 | {SCRIPT_ICONS.map((entry) => ( | |
| 232 | <SelectItem key={entry.id} value={entry.id}> | |
| 233 | {entry.label} | |
| 234 | </SelectItem> | |
| 235 | ))} | |
| 236 | </SelectPopup> | |
| 237 | </Select> | |
| 238 | </div> | |
| 239 | <label className="flex items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2 text-sm"> | |
| 240 | <span>Run automatically on worktree creation (Setup)</span> | |
| 241 | <Switch | |
| 242 | checked={runOnWorktreeCreate} | |
| 243 | onCheckedChange={(checked) => setRunOnWorktreeCreate(Boolean(checked))} | |
| 244 | /> | |
| 245 | </label> | |
| 246 | {validationError && <p className="text-sm text-destructive">{validationError}</p>} | |
| 247 | </DialogPanel> | |
| 248 | <DialogFooter> | |
| 249 | <Button type="button" variant="outline" onClick={() => setDialogOpen(false)}> | |
| 250 | Cancel | |
| 251 | </Button> | |
| 252 | <Button type="submit">Save action</Button> | |
| 253 | </DialogFooter> | |
| 254 | </form> | |
| 255 | </DialogPopup> | |
| 256 | </Dialog> | |
| 257 | </> | |
| 258 | ); | |
| 259 | } |
apps/web/src/components/Sidebar.tsx+2| ... | ... | @@ -214,6 +214,7 @@ export default function Sidebar() { |
| 214 | 214 | cwd: result.project.cwd, |
| 215 | 215 | model: DEFAULT_MODEL, |
| 216 | 216 | expanded: true, |
| 217 | scripts: result.project.scripts, | |
| 217 | 218 | }; |
| 218 | 219 | const existingById = state.projects.find((p) => p.id === project.id); |
| 219 | 220 | const existingByCwd = state.projects.find((p) => p.cwd === project.cwd); |
| ... | ... | @@ -241,6 +242,7 @@ export default function Sidebar() { |
| 241 | 242 | cwd, |
| 242 | 243 | model: DEFAULT_MODEL, |
| 243 | 244 | expanded: true, |
| 245 | scripts: [], | |
| 244 | 246 | }; |
| 245 | 247 | dispatch({ type: "ADD_PROJECT", project }); |
| 246 | 248 | handleNewThread(project.id); |
apps/web/src/keybindings.test.ts+16| ... | ... | @@ -16,6 +16,7 @@ import { |
| 16 | 16 | isTerminalNewShortcut, |
| 17 | 17 | isTerminalSplitShortcut, |
| 18 | 18 | isTerminalToggleShortcut, |
| 19 | resolveShortcutCommand, | |
| 19 | 20 | shortcutLabelForCommand, |
| 20 | 21 | terminalNavigationShortcutData, |
| 21 | 22 | type ShortcutEventLike, |
| ... | ... | @@ -339,6 +340,21 @@ describe("cross-command precedence", () => { |
| 339 | 340 | }); |
| 340 | 341 | }); |
| 341 | 342 | |
| 343 | describe("resolveShortcutCommand", () => { | |
| 344 | it("returns dynamic script commands", () => { | |
| 345 | const keybindings = compile([ | |
| 346 | { shortcut: modShortcut("r"), command: "script.setup.run" }, | |
| 347 | ]); | |
| 348 | ||
| 349 | assert.strictEqual( | |
| 350 | resolveShortcutCommand(event({ key: "r", ctrlKey: true }), keybindings, { | |
| 351 | platform: "Linux", | |
| 352 | }), | |
| 353 | "script.setup.run", | |
| 354 | ); | |
| 355 | }); | |
| 356 | }); | |
| 357 | ||
| 342 | 358 | describe("formatShortcutLabel", () => { |
| 343 | 359 | it("formats labels for macOS", () => { |
| 344 | 360 | assert.strictEqual( |
apps/web/src/keybindings.ts+2-2| ... | ... | @@ -100,11 +100,11 @@ function matchesCommandShortcut( |
| 100 | 100 | return resolveShortcutCommand(event, keybindings, options) === command; |
| 101 | 101 | } |
| 102 | 102 | |
| 103 | function resolveShortcutCommand( | |
| 103 | export function resolveShortcutCommand( | |
| 104 | 104 | event: ShortcutEventLike, |
| 105 | 105 | keybindings: ResolvedKeybindingsConfig, |
| 106 | 106 | options?: ShortcutMatchOptions, |
| 107 | ): KeybindingCommand | null { | |
| 107 | ): string | null { | |
| 108 | 108 | const platform = resolvePlatform(options); |
| 109 | 109 | const context = resolveContext(options); |
| 110 | 110 |
apps/web/src/persistenceSchema.test.ts+7| ... | ... | @@ -20,6 +20,7 @@ describe("hydratePersistedState", () => { |
| 20 | 20 | cwd: "/tmp/project", |
| 21 | 21 | model: "gpt-5.2-codex", |
| 22 | 22 | expanded: true, |
| 23 | scripts: [], | |
| 23 | 24 | }, |
| 24 | 25 | ], |
| 25 | 26 | threads: [ |
| ... | ... | @@ -69,6 +70,7 @@ describe("hydratePersistedState", () => { |
| 69 | 70 | cwd: "/tmp/project", |
| 70 | 71 | model: "gpt-5.3-codex", |
| 71 | 72 | expanded: false, |
| 73 | scripts: [], | |
| 72 | 74 | }, |
| 73 | 75 | ], |
| 74 | 76 | threads: [ |
| ... | ... | @@ -119,6 +121,7 @@ describe("hydratePersistedState", () => { |
| 119 | 121 | cwd: "/tmp/project", |
| 120 | 122 | model: "gpt-5.3-codex", |
| 121 | 123 | expanded: false, |
| 124 | scripts: [], | |
| 122 | 125 | }, |
| 123 | 126 | ], |
| 124 | 127 | threads: [], |
| ... | ... | @@ -140,6 +143,7 @@ describe("hydratePersistedState", () => { |
| 140 | 143 | cwd: "/tmp/project", |
| 141 | 144 | model: "gpt-5.3-codex", |
| 142 | 145 | expanded: true, |
| 146 | scripts: [], | |
| 143 | 147 | }, |
| 144 | 148 | ], |
| 145 | 149 | threads: [ |
| ... | ... | @@ -187,6 +191,7 @@ describe("hydratePersistedState", () => { |
| 187 | 191 | cwd: "/tmp/project", |
| 188 | 192 | model: "gpt-5.3-codex", |
| 189 | 193 | expanded: true, |
| 194 | scripts: [], | |
| 190 | 195 | }, |
| 191 | 196 | ], |
| 192 | 197 | threads: [ |
| ... | ... | @@ -231,6 +236,7 @@ describe("hydratePersistedState", () => { |
| 231 | 236 | cwd: "/tmp/project", |
| 232 | 237 | model: "gpt-5.3-codex", |
| 233 | 238 | expanded: true, |
| 239 | scripts: [], | |
| 234 | 240 | }, |
| 235 | 241 | ], |
| 236 | 242 | threads: [ |
| ... | ... | @@ -313,6 +319,7 @@ describe("toPersistedState", () => { |
| 313 | 319 | cwd: "/tmp/project", |
| 314 | 320 | model: "gpt-5.3-codex", |
| 315 | 321 | expanded: true, |
| 322 | scripts: [], | |
| 316 | 323 | }, |
| 317 | 324 | ], |
| 318 | 325 | threads: [thread], |
apps/web/src/persistenceSchema.ts+3| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { z } from "zod"; |
| 2 | import { projectScriptSchema } from "@t3tools/contracts"; | |
| 2 | 3 | |
| 3 | 4 | import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic"; |
| 4 | 5 | import { |
| ... | ... | @@ -19,6 +20,7 @@ const persistedProjectSchema = z.object({ |
| 19 | 20 | cwd: z.string().min(1), |
| 20 | 21 | model: z.string().min(1), |
| 21 | 22 | expanded: z.boolean(), |
| 23 | scripts: z.array(projectScriptSchema).default([]), | |
| 22 | 24 | }); |
| 23 | 25 | |
| 24 | 26 | const persistedMessageSchema = z.object({ |
| ... | ... | @@ -140,6 +142,7 @@ function hydrateProject( |
| 140 | 142 | return { |
| 141 | 143 | ...project, |
| 142 | 144 | model: resolveModelSlug(maybeMigrateLegacyModel(project.model, isLegacyPayload)), |
| 145 | scripts: project.scripts, | |
| 143 | 146 | }; |
| 144 | 147 | } |
| 145 | 148 |
apps/web/src/projectScripts.test.ts created+57| ... | ... | @@ -0,0 +1,57 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { | |
| 4 | commandForProjectScript, | |
| 5 | injectEnvIntoShellCommand, | |
| 6 | nextProjectScriptId, | |
| 7 | primaryProjectScript, | |
| 8 | projectScriptIdFromCommand, | |
| 9 | setupProjectScript, | |
| 10 | } from "./projectScripts"; | |
| 11 | ||
| 12 | describe("projectScripts helpers", () => { | |
| 13 | it("builds and parses script run commands", () => { | |
| 14 | const command = commandForProjectScript("lint"); | |
| 15 | expect(command).toBe("script.lint.run"); | |
| 16 | expect(projectScriptIdFromCommand(command)).toBe("lint"); | |
| 17 | expect(projectScriptIdFromCommand("terminal.toggle")).toBeNull(); | |
| 18 | }); | |
| 19 | ||
| 20 | it("slugifies and dedupes project script ids", () => { | |
| 21 | expect(nextProjectScriptId("Run Tests", [])).toBe("run-tests"); | |
| 22 | expect(nextProjectScriptId("Run Tests", ["run-tests"])).toBe("run-tests-2"); | |
| 23 | expect(nextProjectScriptId("!!!", [])).toBe("script"); | |
| 24 | }); | |
| 25 | ||
| 26 | it("injects environment variables for posix shells", () => { | |
| 27 | const command = injectEnvIntoShellCommand("bun install", { T3CODE_PROJECT_ROOT: "/tmp/project" }, "MacIntel"); | |
| 28 | expect(command).toBe("env T3CODE_PROJECT_ROOT='/tmp/project' bun install"); | |
| 29 | }); | |
| 30 | ||
| 31 | it("injects environment variables for windows shells", () => { | |
| 32 | const command = injectEnvIntoShellCommand("bun install", { T3CODE_PROJECT_ROOT: "C:\\\\repo path" }, "Win32"); | |
| 33 | expect(command).toBe('set "T3CODE_PROJECT_ROOT=C:\\\\repo path" && bun install'); | |
| 34 | }); | |
| 35 | ||
| 36 | it("resolves primary and setup scripts", () => { | |
| 37 | const scripts = [ | |
| 38 | { | |
| 39 | id: "setup", | |
| 40 | name: "Setup", | |
| 41 | command: "bun install", | |
| 42 | icon: "configure" as const, | |
| 43 | runOnWorktreeCreate: true, | |
| 44 | }, | |
| 45 | { | |
| 46 | id: "test", | |
| 47 | name: "Test", | |
| 48 | command: "bun test", | |
| 49 | icon: "test" as const, | |
| 50 | runOnWorktreeCreate: false, | |
| 51 | }, | |
| 52 | ]; | |
| 53 | ||
| 54 | expect(primaryProjectScript(scripts)?.id).toBe("test"); | |
| 55 | expect(setupProjectScript(scripts)?.id).toBe("setup"); | |
| 56 | }); | |
| 57 | }); |
apps/web/src/projectScripts.ts created+92| ... | ... | @@ -0,0 +1,92 @@ |
| 1 | import type { ProjectScript } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | import { isWindowsPlatform } from "./lib/utils"; | |
| 4 | ||
| 5 | const SCRIPT_RUN_COMMAND_PATTERN = /^script\.([a-z0-9][a-z0-9-]*)\.run$/; | |
| 6 | const MAX_SCRIPT_ID_LENGTH = 64; | |
| 7 | const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; | |
| 8 | ||
| 9 | function normalizeScriptId(value: string): string { | |
| 10 | const cleaned = value | |
| 11 | .trim() | |
| 12 | .toLowerCase() | |
| 13 | .replace(/[^a-z0-9]+/g, "-") | |
| 14 | .replace(/^-+|-+$/g, ""); | |
| 15 | if (cleaned.length === 0) { | |
| 16 | return "script"; | |
| 17 | } | |
| 18 | if (cleaned.length <= MAX_SCRIPT_ID_LENGTH) { | |
| 19 | return cleaned; | |
| 20 | } | |
| 21 | return cleaned.slice(0, MAX_SCRIPT_ID_LENGTH).replace(/-+$/g, "") || "script"; | |
| 22 | } | |
| 23 | ||
| 24 | export function commandForProjectScript(scriptId: string): string { | |
| 25 | return `script.${scriptId}.run`; | |
| 26 | } | |
| 27 | ||
| 28 | export function projectScriptIdFromCommand(command: string): string | null { | |
| 29 | const match = SCRIPT_RUN_COMMAND_PATTERN.exec(command.trim()); | |
| 30 | return match?.[1] ?? null; | |
| 31 | } | |
| 32 | ||
| 33 | export function nextProjectScriptId(name: string, existingIds: Iterable<string>): string { | |
| 34 | const taken = new Set(Array.from(existingIds)); | |
| 35 | const baseId = normalizeScriptId(name); | |
| 36 | if (!taken.has(baseId)) return baseId; | |
| 37 | ||
| 38 | let suffix = 2; | |
| 39 | while (suffix < 10_000) { | |
| 40 | const candidate = `${baseId}-${suffix}`; | |
| 41 | const safeCandidate = | |
| 42 | candidate.length <= MAX_SCRIPT_ID_LENGTH | |
| 43 | ? candidate | |
| 44 | : `${baseId.slice(0, Math.max(1, MAX_SCRIPT_ID_LENGTH - String(suffix).length - 1))}-${suffix}`; | |
| 45 | if (!taken.has(safeCandidate)) { | |
| 46 | return safeCandidate; | |
| 47 | } | |
| 48 | suffix += 1; | |
| 49 | } | |
| 50 | ||
| 51 | return `${baseId}-${Date.now()}`.slice(0, MAX_SCRIPT_ID_LENGTH); | |
| 52 | } | |
| 53 | ||
| 54 | function shellEscapePosix(value: string): string { | |
| 55 | return `'${value.replace(/'/g, `'\\''`)}'`; | |
| 56 | } | |
| 57 | ||
| 58 | function shellEscapeWindowsSetValue(value: string): string { | |
| 59 | return value.replace(/"/g, '""'); | |
| 60 | } | |
| 61 | ||
| 62 | export function injectEnvIntoShellCommand( | |
| 63 | command: string, | |
| 64 | env: Record<string, string>, | |
| 65 | platform = typeof navigator === "undefined" ? "" : navigator.platform, | |
| 66 | ): string { | |
| 67 | const entries = Object.entries(env).filter( | |
| 68 | ([key, value]) => ENV_KEY_PATTERN.test(key) && value.length > 0, | |
| 69 | ); | |
| 70 | if (entries.length === 0) { | |
| 71 | return command; | |
| 72 | } | |
| 73 | ||
| 74 | if (isWindowsPlatform(platform)) { | |
| 75 | const prefixes = entries.map( | |
| 76 | ([key, value]) => `set "${key}=${shellEscapeWindowsSetValue(value)}"`, | |
| 77 | ); | |
| 78 | return `${prefixes.join(" && ")} && ${command}`; | |
| 79 | } | |
| 80 | ||
| 81 | const assignments = entries.map(([key, value]) => `${key}=${shellEscapePosix(value)}`).join(" "); | |
| 82 | return `env ${assignments} ${command}`; | |
| 83 | } | |
| 84 | ||
| 85 | export function primaryProjectScript(scripts: ProjectScript[]): ProjectScript | null { | |
| 86 | const regular = scripts.find((script) => !script.runOnWorktreeCreate); | |
| 87 | return regular ?? scripts[0] ?? null; | |
| 88 | } | |
| 89 | ||
| 90 | export function setupProjectScript(scripts: ProjectScript[]): ProjectScript | null { | |
| 91 | return scripts.find((script) => script.runOnWorktreeCreate) ?? null; | |
| 92 | } |
apps/web/src/store.test.ts+33| ... | ... | @@ -74,6 +74,7 @@ function makeState(thread: Thread): AppState { |
| 74 | 74 | cwd: "/tmp/project", |
| 75 | 75 | model: "gpt-5.3-codex", |
| 76 | 76 | expanded: true, |
| 77 | scripts: [], | |
| 77 | 78 | }, |
| 78 | 79 | ], |
| 79 | 80 | threads: [thread], |
| ... | ... | @@ -507,6 +508,7 @@ describe("store reducer thread continuity", () => { |
| 507 | 508 | cwd: "/tmp/a", |
| 508 | 509 | model: "gpt-5.3-codex", |
| 509 | 510 | expanded: false, |
| 511 | scripts: [], | |
| 510 | 512 | }, |
| 511 | 513 | { |
| 512 | 514 | id: "project-old-b", |
| ... | ... | @@ -514,6 +516,7 @@ describe("store reducer thread continuity", () => { |
| 514 | 516 | cwd: "/tmp/b", |
| 515 | 517 | model: "gpt-5.3-codex", |
| 516 | 518 | expanded: true, |
| 519 | scripts: [], | |
| 517 | 520 | }, |
| 518 | 521 | ], |
| 519 | 522 | threads: [ |
| ... | ... | @@ -540,6 +543,7 @@ describe("store reducer thread continuity", () => { |
| 540 | 543 | cwd: "/tmp/a", |
| 541 | 544 | model: "gpt-5.3-codex", |
| 542 | 545 | expanded: true, |
| 546 | scripts: [], | |
| 543 | 547 | }, |
| 544 | 548 | ], |
| 545 | 549 | }); |
| ... | ... | @@ -554,6 +558,33 @@ describe("store reducer thread continuity", () => { |
| 554 | 558 | expect(next.activeThreadId).toBe("thread-a"); |
| 555 | 559 | }); |
| 556 | 560 | |
| 561 | it("updates project scripts", () => { | |
| 562 | const state = makeState(makeThread()); | |
| 563 | const next = reducer(state, { | |
| 564 | type: "SET_PROJECT_SCRIPTS", | |
| 565 | projectId: "project-1", | |
| 566 | scripts: [ | |
| 567 | { | |
| 568 | id: "test", | |
| 569 | name: "Test", | |
| 570 | command: "bun test", | |
| 571 | icon: "test", | |
| 572 | runOnWorktreeCreate: false, | |
| 573 | }, | |
| 574 | ], | |
| 575 | }); | |
| 576 | ||
| 577 | expect(next.projects[0]?.scripts).toEqual([ | |
| 578 | { | |
| 579 | id: "test", | |
| 580 | name: "Test", | |
| 581 | command: "bun test", | |
| 582 | icon: "test", | |
| 583 | runOnWorktreeCreate: false, | |
| 584 | }, | |
| 585 | ]); | |
| 586 | }); | |
| 587 | ||
| 557 | 588 | it("deletes a project and all of its threads", () => { |
| 558 | 589 | const state: AppState = { |
| 559 | 590 | projects: [ |
| ... | ... | @@ -563,6 +594,7 @@ describe("store reducer thread continuity", () => { |
| 563 | 594 | cwd: "/tmp/one", |
| 564 | 595 | model: "gpt-5.3-codex", |
| 565 | 596 | expanded: true, |
| 597 | scripts: [], | |
| 566 | 598 | }, |
| 567 | 599 | { |
| 568 | 600 | id: "project-2", |
| ... | ... | @@ -570,6 +602,7 @@ describe("store reducer thread continuity", () => { |
| 570 | 602 | cwd: "/tmp/two", |
| 571 | 603 | model: "gpt-5.3-codex", |
| 572 | 604 | expanded: true, |
| 605 | scripts: [], | |
| 573 | 606 | }, |
| 574 | 607 | ], |
| 575 | 608 | threads: [ |
apps/web/src/store.ts+50| ... | ... | @@ -17,6 +17,7 @@ import { |
| 17 | 17 | DEFAULT_THREAD_TERMINAL_ID, |
| 18 | 18 | DEFAULT_RUNTIME_MODE, |
| 19 | 19 | MAX_THREAD_TERMINAL_COUNT, |
| 20 | type ProjectScript, | |
| 20 | 21 | type Project, |
| 21 | 22 | type RuntimeMode, |
| 22 | 23 | type Thread, |
| ... | ... | @@ -27,6 +28,7 @@ import { |
| 27 | 28 | |
| 28 | 29 | type Action = |
| 29 | 30 | | { type: "ADD_PROJECT"; project: Project } |
| 31 | | { type: "SET_PROJECT_SCRIPTS"; projectId: string; scripts: ProjectScript[] } | |
| 30 | 32 | | { type: "SYNC_PROJECTS"; projects: Project[] } |
| 31 | 33 | | { type: "TOGGLE_PROJECT"; projectId: string } |
| 32 | 34 | | { type: "DELETE_PROJECT"; projectId: string } |
| ... | ... | @@ -163,6 +165,38 @@ function normalizeRunningTerminalIds( |
| 163 | 165 | .filter((id) => id.length > 0 && validTerminalIdSet.has(id)); |
| 164 | 166 | } |
| 165 | 167 | |
| 168 | function normalizeProjectScripts(scripts: ProjectScript[]): ProjectScript[] { | |
| 169 | const deduped: ProjectScript[] = []; | |
| 170 | const seenIds = new Set<string>(); | |
| 171 | for (const script of scripts) { | |
| 172 | const id = script.id.trim(); | |
| 173 | if (id.length === 0 || seenIds.has(id)) continue; | |
| 174 | seenIds.add(id); | |
| 175 | deduped.push({ | |
| 176 | ...script, | |
| 177 | id, | |
| 178 | name: script.name.trim(), | |
| 179 | command: script.command.trim(), | |
| 180 | }); | |
| 181 | } | |
| 182 | ||
| 183 | let setupAssigned = false; | |
| 184 | const normalized: ProjectScript[] = []; | |
| 185 | for (const script of deduped) { | |
| 186 | if (!script.runOnWorktreeCreate) { | |
| 187 | normalized.push(script); | |
| 188 | continue; | |
| 189 | } | |
| 190 | if (!setupAssigned) { | |
| 191 | setupAssigned = true; | |
| 192 | normalized.push(script); | |
| 193 | continue; | |
| 194 | } | |
| 195 | normalized.push({ ...script, runOnWorktreeCreate: false }); | |
| 196 | } | |
| 197 | return normalized; | |
| 198 | } | |
| 199 | ||
| 166 | 200 | function normalizeTerminalGroupIds(terminalIds: string[]): string[] { |
| 167 | 201 | return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; |
| 168 | 202 | } |
| ... | ... | @@ -426,20 +460,36 @@ export function reducer(state: AppState, action: Action): AppState { |
| 426 | 460 | { |
| 427 | 461 | ...action.project, |
| 428 | 462 | model: resolveModelSlug(action.project.model), |
| 463 | scripts: normalizeProjectScripts(action.project.scripts), | |
| 429 | 464 | }, |
| 430 | 465 | ], |
| 431 | 466 | }; |
| 432 | 467 | |
| 468 | case "SET_PROJECT_SCRIPTS": | |
| 469 | return { | |
| 470 | ...state, | |
| 471 | projects: state.projects.map((project) => | |
| 472 | project.id === action.projectId | |
| 473 | ? { ...project, scripts: normalizeProjectScripts(action.scripts) } | |
| 474 | : project, | |
| 475 | ), | |
| 476 | }; | |
| 477 | ||
| 433 | 478 | case "SYNC_PROJECTS": { |
| 434 | 479 | const previousByCwd = new Map( |
| 435 | 480 | state.projects.map((project) => [project.cwd, project] as const), |
| 436 | 481 | ); |
| 437 | 482 | const nextProjects = action.projects.map((project) => { |
| 438 | 483 | const previous = previousByCwd.get(project.cwd); |
| 484 | const scripts = | |
| 485 | project.scripts.length > 0 | |
| 486 | ? normalizeProjectScripts(project.scripts) | |
| 487 | : normalizeProjectScripts(previous?.scripts ?? []); | |
| 439 | 488 | return { |
| 440 | 489 | ...project, |
| 441 | 490 | model: resolveModelSlug(previous?.model ?? project.model), |
| 442 | 491 | expanded: previous?.expanded ?? project.expanded, |
| 492 | scripts, | |
| 443 | 493 | }; |
| 444 | 494 | }); |
| 445 | 495 | const previousProjectById = new Map( |
apps/web/src/types.ts+7-1| ... | ... | @@ -1,4 +1,8 @@ |
| 1 | import type { ProviderEvent, ProviderSession } from "@t3tools/contracts"; | |
| 1 | import type { | |
| 2 | ProjectScript as ContractProjectScript, | |
| 3 | ProviderEvent, | |
| 4 | ProviderSession, | |
| 5 | } from "@t3tools/contracts"; | |
| 2 | 6 | |
| 3 | 7 | export type SessionPhase = "disconnected" | "connecting" | "ready" | "running"; |
| 4 | 8 | export type RuntimeMode = "approval-required" | "full-access"; |
| ... | ... | @@ -6,6 +10,7 @@ export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; |
| 6 | 10 | export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280; |
| 7 | 11 | export const DEFAULT_THREAD_TERMINAL_ID = "default"; |
| 8 | 12 | export const MAX_THREAD_TERMINAL_COUNT = 4; |
| 13 | export type ProjectScript = ContractProjectScript; | |
| 9 | 14 | |
| 10 | 15 | export interface ThreadTerminalGroup { |
| 11 | 16 | id: string; |
| ... | ... | @@ -38,6 +43,7 @@ export interface Project { |
| 38 | 43 | cwd: string; |
| 39 | 44 | model: string; |
| 40 | 45 | expanded: boolean; |
| 46 | scripts: ProjectScript[]; | |
| 41 | 47 | } |
| 42 | 48 | |
| 43 | 49 | export interface Thread { |
apps/web/src/wsNativeApi.ts+1| ... | ... | @@ -99,6 +99,7 @@ export function createWsNativeApi(): NativeApi { |
| 99 | 99 | add: (input) => transport.request(WS_METHODS.projectsAdd, input), |
| 100 | 100 | remove: (input) => transport.request(WS_METHODS.projectsRemove, input), |
| 101 | 101 | searchEntries: (input) => transport.request(WS_METHODS.projectsSearchEntries, input), |
| 102 | updateScripts: (input) => transport.request(WS_METHODS.projectsUpdateScripts, input), | |
| 102 | 103 | }, |
| 103 | 104 | shell: { |
| 104 | 105 | openInEditor: (cwd, editor) => |
packages/contracts/src/ipc.ts+3| ... | ... | @@ -32,6 +32,8 @@ import type { |
| 32 | 32 | ProjectSearchEntriesInput, |
| 33 | 33 | ProjectSearchEntriesResult, |
| 34 | 34 | ProjectRemoveInput, |
| 35 | ProjectUpdateScriptsInput, | |
| 36 | ProjectUpdateScriptsResult, | |
| 35 | 37 | } from "./project"; |
| 36 | 38 | import type { ServerConfig } from "./server"; |
| 37 | 39 | import type { |
| ... | ... | @@ -93,6 +95,7 @@ export interface NativeApi { |
| 93 | 95 | add: (input: ProjectAddInput) => Promise<ProjectAddResult>; |
| 94 | 96 | remove: (input: ProjectRemoveInput) => Promise<void>; |
| 95 | 97 | searchEntries: (input: ProjectSearchEntriesInput) => Promise<ProjectSearchEntriesResult>; |
| 98 | updateScripts: (input: ProjectUpdateScriptsInput) => Promise<ProjectUpdateScriptsResult>; | |
| 96 | 99 | }; |
| 97 | 100 | shell: { |
| 98 | 101 | openInEditor: (cwd: string, editor: EditorId) => Promise<void>; |
packages/contracts/src/keybindings.test.ts+9-1| ... | ... | @@ -32,11 +32,19 @@ describe("keybindings contracts", () => { |
| 32 | 32 | assert.throws(() => |
| 33 | 33 | keybindingRuleSchema.parse({ |
| 34 | 34 | key: "mod+j", |
| 35 | command: "invalid.command", | |
| 35 | command: "script.Test.run", | |
| 36 | 36 | }), |
| 37 | 37 | ); |
| 38 | 38 | }); |
| 39 | 39 | |
| 40 | it("accepts dynamic script run commands", () => { | |
| 41 | const parsed = keybindingRuleSchema.parse({ | |
| 42 | key: "mod+r", | |
| 43 | command: "script.setup.run", | |
| 44 | }); | |
| 45 | assert.strictEqual(parsed.command, "script.setup.run"); | |
| 46 | }); | |
| 47 | ||
| 40 | 48 | it("parses keybindings array payload", () => { |
| 41 | 49 | const parsed = keybindingsConfigSchema.parse([ |
| 42 | 50 | { key: "mod+j", command: "terminal.toggle" }, |
packages/contracts/src/keybindings.ts+11-1| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import { z } from "zod"; |
| 2 | 2 | |
| 3 | export const keybindingCommandSchema = z.enum([ | |
| 3 | export const STATIC_KEYBINDING_COMMANDS = [ | |
| 4 | 4 | "terminal.toggle", |
| 5 | 5 | "terminal.split", |
| 6 | 6 | "terminal.new", |
| ... | ... | @@ -8,6 +8,16 @@ export const keybindingCommandSchema = z.enum([ |
| 8 | 8 | "chat.new", |
| 9 | 9 | "chat.newLocal", |
| 10 | 10 | "editor.openFavorite", |
| 11 | ] as const; | |
| 12 | ||
| 13 | export const keybindingCommandSchema = z.union([ | |
| 14 | z.enum(STATIC_KEYBINDING_COMMANDS), | |
| 15 | z | |
| 16 | .string() | |
| 17 | .trim() | |
| 18 | .min(1) | |
| 19 | .max(96) | |
| 20 | .regex(/^script\.[a-z0-9][a-z0-9-]*\.run$/), | |
| 11 | 21 | ]); |
| 12 | 22 | |
| 13 | 23 | const keybindingValueSchema = z.string().trim().min(1).max(64); |
packages/contracts/src/project.test.ts+45| ... | ... | @@ -7,6 +7,8 @@ import { |
| 7 | 7 | projectSearchEntriesResultSchema, |
| 8 | 8 | projectListResultSchema, |
| 9 | 9 | projectRemoveInputSchema, |
| 10 | projectUpdateScriptsInputSchema, | |
| 11 | projectUpdateScriptsResultSchema, | |
| 10 | 12 | } from "./project"; |
| 11 | 13 | |
| 12 | 14 | describe("project contracts", () => { |
| ... | ... | @@ -16,6 +18,7 @@ describe("project contracts", () => { |
| 16 | 18 | id: "project-1", |
| 17 | 19 | cwd: "/tmp/project", |
| 18 | 20 | name: "project", |
| 21 | scripts: [], | |
| 19 | 22 | createdAt: "2026-02-10T00:00:00.000Z", |
| 20 | 23 | updatedAt: "2026-02-10T00:00:00.000Z", |
| 21 | 24 | }, |
| ... | ... | @@ -36,6 +39,7 @@ describe("project contracts", () => { |
| 36 | 39 | id: "project-1", |
| 37 | 40 | cwd: "/tmp/project", |
| 38 | 41 | name: "project", |
| 42 | scripts: [], | |
| 39 | 43 | createdAt: "2026-02-10T00:00:00.000Z", |
| 40 | 44 | updatedAt: "2026-02-10T00:00:00.000Z", |
| 41 | 45 | }, |
| ... | ... | @@ -82,4 +86,45 @@ describe("project contracts", () => { |
| 82 | 86 | expect(parsed.entries).toHaveLength(2); |
| 83 | 87 | expect(parsed.entries[0]?.kind).toBe("directory"); |
| 84 | 88 | }); |
| 89 | ||
| 90 | it("parses update scripts input", () => { | |
| 91 | const parsed = projectUpdateScriptsInputSchema.parse({ | |
| 92 | id: "project-1", | |
| 93 | scripts: [ | |
| 94 | { | |
| 95 | id: "test", | |
| 96 | name: "Test", | |
| 97 | command: "bun test", | |
| 98 | icon: "test", | |
| 99 | runOnWorktreeCreate: false, | |
| 100 | }, | |
| 101 | ], | |
| 102 | }); | |
| 103 | ||
| 104 | expect(parsed.scripts).toHaveLength(1); | |
| 105 | expect(parsed.scripts[0]?.id).toBe("test"); | |
| 106 | }); | |
| 107 | ||
| 108 | it("parses update scripts result", () => { | |
| 109 | const parsed = projectUpdateScriptsResultSchema.parse({ | |
| 110 | project: { | |
| 111 | id: "project-1", | |
| 112 | cwd: "/tmp/project", | |
| 113 | name: "project", | |
| 114 | scripts: [ | |
| 115 | { | |
| 116 | id: "setup", | |
| 117 | name: "Setup", | |
| 118 | command: "bun install", | |
| 119 | icon: "configure", | |
| 120 | runOnWorktreeCreate: true, | |
| 121 | }, | |
| 122 | ], | |
| 123 | createdAt: "2026-02-10T00:00:00.000Z", | |
| 124 | updatedAt: "2026-02-10T00:00:00.000Z", | |
| 125 | }, | |
| 126 | }); | |
| 127 | ||
| 128 | expect(parsed.project.scripts[0]?.id).toBe("setup"); | |
| 129 | }); | |
| 85 | 130 | }); |
packages/contracts/src/project.ts+35| ... | ... | @@ -1,9 +1,31 @@ |
| 1 | 1 | import { z } from "zod"; |
| 2 | 2 | |
| 3 | export const PROJECT_SCRIPT_ICON_VALUES = ["play", "test", "lint", "configure", "build"] as const; | |
| 4 | ||
| 5 | export const projectScriptIconSchema = z.enum(PROJECT_SCRIPT_ICON_VALUES); | |
| 6 | ||
| 7 | export const projectScriptSchema = z | |
| 8 | .object({ | |
| 9 | id: z | |
| 10 | .string() | |
| 11 | .trim() | |
| 12 | .min(1) | |
| 13 | .max(64) | |
| 14 | .regex(/^[a-z0-9][a-z0-9-]*$/), | |
| 15 | name: z.string().trim().min(1).max(64), | |
| 16 | command: z.string().trim().min(1).max(10_000), | |
| 17 | icon: projectScriptIconSchema, | |
| 18 | runOnWorktreeCreate: z.boolean().default(false), | |
| 19 | }) | |
| 20 | .strict(); | |
| 21 | ||
| 22 | export const projectScriptsSchema = z.array(projectScriptSchema).max(64); | |
| 23 | ||
| 3 | 24 | export const projectRecordSchema = z.object({ |
| 4 | 25 | id: z.string().min(1), |
| 5 | 26 | cwd: z.string().min(1), |
| 6 | 27 | name: z.string().min(1), |
| 28 | scripts: projectScriptsSchema.default([]), | |
| 7 | 29 | createdAt: z.string().datetime(), |
| 8 | 30 | updatedAt: z.string().datetime(), |
| 9 | 31 | }); |
| ... | ... | @@ -44,6 +66,17 @@ export const projectSearchEntriesResultSchema = z.object({ |
| 44 | 66 | truncated: z.boolean(), |
| 45 | 67 | }); |
| 46 | 68 | |
| 69 | export const projectUpdateScriptsInputSchema = z.object({ | |
| 70 | id: z.string().min(1), | |
| 71 | scripts: projectScriptsSchema, | |
| 72 | }); | |
| 73 | ||
| 74 | export const projectUpdateScriptsResultSchema = z.object({ | |
| 75 | project: projectRecordSchema, | |
| 76 | }); | |
| 77 | ||
| 78 | export type ProjectScriptIcon = z.infer<typeof projectScriptIconSchema>; | |
| 79 | export type ProjectScript = z.infer<typeof projectScriptSchema>; | |
| 47 | 80 | export type ProjectRecord = z.infer<typeof projectRecordSchema>; |
| 48 | 81 | export type ProjectListResult = z.infer<typeof projectListResultSchema>; |
| 49 | 82 | export type ProjectAddInput = z.input<typeof projectAddInputSchema>; |
| ... | ... | @@ -53,3 +86,5 @@ export type ProjectSearchEntriesInput = z.input<typeof projectSearchEntriesInput |
| 53 | 86 | export type ProjectEntryKind = z.infer<typeof projectEntryKindSchema>; |
| 54 | 87 | export type ProjectEntry = z.infer<typeof projectEntrySchema>; |
| 55 | 88 | export type ProjectSearchEntriesResult = z.infer<typeof projectSearchEntriesResultSchema>; |
| 89 | export type ProjectUpdateScriptsInput = z.input<typeof projectUpdateScriptsInputSchema>; | |
| 90 | export type ProjectUpdateScriptsResult = z.infer<typeof projectUpdateScriptsResultSchema>; |
packages/contracts/src/ws.ts+1| ... | ... | @@ -16,6 +16,7 @@ export const WS_METHODS = { |
| 16 | 16 | projectsAdd: "projects.add", |
| 17 | 17 | projectsRemove: "projects.remove", |
| 18 | 18 | projectsSearchEntries: "projects.searchEntries", |
| 19 | projectsUpdateScripts: "projects.updateScripts", | |
| 19 | 20 | |
| 20 | 21 | // Shell methods |
| 21 | 22 | shellOpenInEditor: "shell.openInEditor", |