- 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 behavior
Unified · Split
| @@ -53,6 +53,7 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged · 52 unmodified lines | ||
| 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 | |
| @@ -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 | +}); | |
| @@ -8,11 +8,30 @@ import { · 7 unmodified lines | ||
| 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 { · 29 unmodified lines | ||
| 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 { · 5 unmodified lines | ||
| 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 { · 1 unmodified line | ||
| 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 { · 7 unmodified lines | ||
| 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 { · 13 unmodified lines | ||
| 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 { · 8 unmodified lines | ||
| 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 { · 43 unmodified lines | ||
| 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 | +} | |
| @@ -756,6 +756,42 @@ describe("WebSocket Server", () => { · 755 unmodified lines | ||
| 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 | }); |
| @@ -322,6 +322,8 @@ export function createServer(options: ServerOptions) { · 321 unmodified lines | ||
| 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 { |
| @@ -85,6 +85,7 @@ function AutoProjectBootstrap() { · 84 unmodified lines | ||
| 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() { · 55 unmodified lines | ||
| 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; |
| @@ -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"; · 56 unmodified lines | ||
| 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"; · 21 unmodified lines | ||
| 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 }) => ({ · 14 unmodified lines | ||
| 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() { · 495 unmodified lines | ||
| 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() { · 169 unmodified lines | ||
| 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() { · 7 unmodified lines | ||
| 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() { · 1 unmodified line | ||
| 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() { · 198 unmodified lines | ||
| 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() { · 272 unmodified lines | ||
| 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() { · 317 unmodified lines | ||
| 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({ · 5 unmodified lines | ||
| 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 |
| @@ -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 | +} | |
| @@ -214,6 +214,7 @@ export default function Sidebar() { · 213 unmodified lines | ||
| 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() { · 21 unmodified lines | ||
| 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); |
| @@ -16,6 +16,7 @@ import { · 15 unmodified lines | ||
| 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", () => { · 317 unmodified lines | ||
| 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( |
| @@ -100,11 +100,11 @@ function matchesCommandShortcut( · 99 unmodified lines | ||
| 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 | |
| @@ -20,6 +20,7 @@ describe("hydratePersistedState", () => { · 19 unmodified lines | ||
| 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", () => { · 43 unmodified lines | ||
| 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", () => { · 44 unmodified lines | ||
| 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", () => { · 15 unmodified lines | ||
| 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", () => { · 41 unmodified lines | ||
| 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", () => { · 38 unmodified lines | ||
| 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", () => { · 76 unmodified lines | ||
| 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], |
| @@ -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({ · 14 unmodified lines | ||
| 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( · 115 unmodified lines | ||
| 140 | 142 | return { |
| 141 | 143 | ...project, |
| 142 | 144 | model: resolveModelSlug(maybeMigrateLegacyModel(project.model, isLegacyPayload)), |
| 145 | + scripts: project.scripts, | |
| 143 | 146 | }; |
| 144 | 147 | } |
| 145 | 148 | |
| @@ -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 | +}); | |
| @@ -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 | +} | |
| @@ -74,6 +74,7 @@ function makeState(thread: Thread): AppState { · 73 unmodified lines | ||
| 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", () => { · 427 unmodified lines | ||
| 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", () => { · 1 unmodified line | ||
| 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", () => { · 20 unmodified lines | ||
| 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", () => { · 8 unmodified lines | ||
| 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", () => { · 3 unmodified lines | ||
| 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", () => { · 1 unmodified line | ||
| 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: [ |
| @@ -17,6 +17,7 @@ import { · 16 unmodified lines | ||
| 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 { · 4 unmodified lines | ||
| 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( · 130 unmodified lines | ||
| 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 { · 257 unmodified lines | ||
| 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( |
| @@ -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"; · 1 unmodified line | ||
| 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 { · 26 unmodified lines | ||
| 38 | 43 | cwd: string; |
| 39 | 44 | model: string; |
| 40 | 45 | expanded: boolean; |
| 46 | + scripts: ProjectScript[]; | |
| 41 | 47 | } |
| 42 | 48 | |
| 43 | 49 | export interface Thread { |
| @@ -99,6 +99,7 @@ export function createWsNativeApi(): NativeApi { · 98 unmodified lines | ||
| 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) => |
| @@ -32,6 +32,8 @@ import type { · 31 unmodified lines | ||
| 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 { · 55 unmodified lines | ||
| 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>; |
| @@ -32,11 +32,19 @@ describe("keybindings contracts", () => { · 31 unmodified lines | ||
| 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" }, |
| @@ -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([ · 1 unmodified line | ||
| 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); |
| @@ -7,6 +7,8 @@ import { · 6 unmodified lines | ||
| 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", () => { · 3 unmodified lines | ||
| 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", () => { · 14 unmodified lines | ||
| 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", () => { · 40 unmodified lines | ||
| 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 | }); |
| @@ -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({ · 34 unmodified lines | ||
| 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 · 3 unmodified lines | ||
| 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>; | |
| @@ -16,6 +16,7 @@ export const WS_METHODS = { · 15 unmodified lines | ||
| 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", |