| 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,6 +53,7 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged |
| 53 | - `chat.new`: create a new chat thread preserving the active thread's branch/worktree state | 53 | - `chat.new`: create a new chat thread preserving the active thread's branch/worktree state |
| 54 | - `chat.newLocal`: create a new local chat thread for the active project (no worktree context) | 54 | - `chat.newLocal`: create a new local chat thread for the active project (no worktree context) |
| 55 | - `editor.openFavorite`: open current project/worktree in the last-used editor | 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 | ### Key Syntax | 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,11 +8,30 @@ import { |
| 8 | type ProjectListResult, | 8 | type ProjectListResult, |
| 9 | type ProjectRecord, | 9 | type ProjectRecord, |
| 10 | type ProjectRemoveInput, | 10 | type ProjectRemoveInput, |
| 11 | type ProjectScript, | ||
| 12 | type ProjectUpdateScriptsInput, | ||
| 13 | type ProjectUpdateScriptsResult, | ||
| 11 | projectAddInputSchema, | 14 | projectAddInputSchema, |
| 12 | projectRecordSchema, | 15 | projectRecordSchema, |
| 13 | projectRemoveInputSchema, | 16 | projectRemoveInputSchema, |
| 17 | projectScriptsSchema, | ||
| 18 | projectUpdateScriptsInputSchema, | ||
| 14 | } from "@t3tools/contracts"; | 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 | function normalizeCwd(rawCwd: string): string { | 35 | function normalizeCwd(rawCwd: string): string { |
| 17 | const resolved = path.resolve(rawCwd.trim()); | 36 | const resolved = path.resolve(rawCwd.trim()); |
| 18 | const normalized = path.normalize(resolved); | 37 | const normalized = path.normalize(resolved); |
| ... | @@ -48,7 +67,10 @@ export class ProjectRegistry { | ... | @@ -48,7 +67,10 @@ export class ProjectRegistry { |
| 48 | } | 67 | } |
| 49 | 68 | ||
| 50 | list(): ProjectListResult { | 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 | add(raw: ProjectAddInput): ProjectAddResult { | 76 | add(raw: ProjectAddInput): ProjectAddResult { |
| ... | @@ -60,7 +82,13 @@ export class ProjectRegistry { | ... | @@ -60,7 +82,13 @@ export class ProjectRegistry { |
| 60 | 82 | ||
| 61 | const existing = this.projects.find((project) => normalizeCwd(project.cwd) === normalizedCwd); | 83 | const existing = this.projects.find((project) => normalizeCwd(project.cwd) === normalizedCwd); |
| 62 | if (existing) { | 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 | const now = new Date().toISOString(); | 94 | const now = new Date().toISOString(); |
| ... | @@ -68,13 +96,20 @@ export class ProjectRegistry { | ... | @@ -68,13 +96,20 @@ export class ProjectRegistry { |
| 68 | id: randomUUID(), | 96 | id: randomUUID(), |
| 69 | cwd: normalizedCwd, | 97 | cwd: normalizedCwd, |
| 70 | name: inferProjectName(normalizedCwd), | 98 | name: inferProjectName(normalizedCwd), |
| 99 | scripts: [], | ||
| 71 | createdAt: now, | 100 | createdAt: now, |
| 72 | updatedAt: now, | 101 | updatedAt: now, |
| 73 | }; | 102 | }; |
| 74 | 103 | ||
| 75 | this.projects = [project, ...this.projects]; | 104 | this.projects = [project, ...this.projects]; |
| 76 | this.persist(); | 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 | remove(raw: ProjectRemoveInput): void { | 115 | remove(raw: ProjectRemoveInput): void { |
| ... | @@ -88,6 +123,31 @@ export class ProjectRegistry { | ... | @@ -88,6 +123,31 @@ export class ProjectRegistry { |
| 88 | this.persist(); | 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 | private loadFromDisk(): ProjectRecord[] { | 151 | private loadFromDisk(): ProjectRecord[] { |
| 92 | const loaded = this.readPersistedProjects(); | 152 | const loaded = this.readPersistedProjects(); |
| 93 | const deduped = new Map<string, ProjectRecord>(); | 153 | const deduped = new Map<string, ProjectRecord>(); |
| ... | @@ -107,6 +167,7 @@ export class ProjectRegistry { | ... | @@ -107,6 +167,7 @@ export class ProjectRegistry { |
| 107 | cwd: normalizedCwd, | 167 | cwd: normalizedCwd, |
| 108 | name: | 168 | name: |
| 109 | project.name.trim().length > 0 ? project.name.trim() : inferProjectName(normalizedCwd), | 169 | project.name.trim().length > 0 ? project.name.trim() : inferProjectName(normalizedCwd), |
| 170 | scripts: dedupeScriptsById(project.scripts), | ||
| 110 | }; | 171 | }; |
| 111 | deduped.set(normalizedCwd, normalizedProject); | 172 | deduped.set(normalizedCwd, normalizedProject); |
| 112 | normalizedProjects.push(normalizedProject); | 173 | normalizedProjects.push(normalizedProject); |
| ... | @@ -121,6 +182,7 @@ export class ProjectRegistry { | ... | @@ -121,6 +182,7 @@ export class ProjectRegistry { |
| 121 | source.id !== project.id || | 182 | source.id !== project.id || |
| 122 | source.cwd !== project.cwd || | 183 | source.cwd !== project.cwd || |
| 123 | source.name !== project.name || | 184 | source.name !== project.name || |
| 185 | JSON.stringify(source.scripts) !== JSON.stringify(project.scripts) || | ||
| 124 | source.createdAt !== project.createdAt || | 186 | source.createdAt !== project.createdAt || |
| 125 | source.updatedAt !== project.updatedAt | 187 | source.updatedAt !== project.updatedAt |
| 126 | ); | 188 | ); |
| ... | @@ -170,3 +232,35 @@ export class ProjectRegistry { | ... | @@ -170,3 +232,35 @@ export class ProjectRegistry { |
| 170 | fs.renameSync(tempFile, this.filePath); | 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,6 +756,42 @@ describe("WebSocket Server", () => { |
| 756 | expect(projectId).toBeTruthy(); | 756 | expect(projectId).toBeTruthy(); |
| 757 | if (!projectId) return; | 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 | const removed = await sendRequest(ws, WS_METHODS.projectsRemove, { | 795 | const removed = await sendRequest(ws, WS_METHODS.projectsRemove, { |
| 760 | id: projectId, | 796 | id: projectId, |
| 761 | }); | 797 | }); |
apps/server/src/wsServer.ts+2| ... | @@ -322,6 +322,8 @@ export function createServer(options: ServerOptions) { | ... | @@ -322,6 +322,8 @@ export function createServer(options: ServerOptions) { |
| 322 | 322 | ||
| 323 | case WS_METHODS.projectsSearchEntries: | 323 | case WS_METHODS.projectsSearchEntries: |
| 324 | return searchWorkspaceEntries(request.params as never); | 324 | return searchWorkspaceEntries(request.params as never); |
| 325 | case WS_METHODS.projectsUpdateScripts: | ||
| 326 | return projectRegistry.updateScripts(request.params as never); | ||
| 325 | 327 | ||
| 326 | case WS_METHODS.shellOpenInEditor: { | 328 | case WS_METHODS.shellOpenInEditor: { |
| 327 | const params = request.params as { | 329 | const params = request.params as { |
apps/web/src/App.tsx+2| ... | @@ -85,6 +85,7 @@ function AutoProjectBootstrap() { | ... | @@ -85,6 +85,7 @@ function AutoProjectBootstrap() { |
| 85 | cwd: payload.cwd, | 85 | cwd: payload.cwd, |
| 86 | model: DEFAULT_MODEL, | 86 | model: DEFAULT_MODEL, |
| 87 | expanded: true, | 87 | expanded: true, |
| 88 | scripts: [], | ||
| 88 | }, | 89 | }, |
| 89 | }); | 90 | }); |
| 90 | dispatch({ | 91 | dispatch({ |
| ... | @@ -146,6 +147,7 @@ function DesktopProjectBootstrap() { | ... | @@ -146,6 +147,7 @@ function DesktopProjectBootstrap() { |
| 146 | cwd: project.cwd, | 147 | cwd: project.cwd, |
| 147 | model: DEFAULT_MODEL, | 148 | model: DEFAULT_MODEL, |
| 148 | expanded: true, | 149 | expanded: true, |
| 150 | scripts: project.scripts, | ||
| 149 | })), | 151 | })), |
| 150 | }); | 152 | }); |
| 151 | bootstrappedRef.current = true; | 153 | bootstrappedRef.current = true; |
apps/web/src/components/ChatView.tsx+184-17| ... | @@ -1,9 +1,10 @@ | ... | @@ -1,9 +1,10 @@ |
| 1 | import { | 1 | import { |
| 2 | EDITORS, | 2 | EDITORS, |
| 3 | type EditorId, | 3 | type EditorId, |
| 4 | type NativeApi, | ||
| 4 | type ProjectEntry, | 5 | type ProjectEntry, |
| 6 | type ProjectScript, | ||
| 5 | ModelSlug, | 7 | ModelSlug, |
| 6 | type NativeApi, | ||
| 7 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, | 8 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, |
| 8 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, | 9 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 9 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, | 10 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, |
| ... | @@ -66,10 +67,7 @@ import BranchToolbar from "./BranchToolbar"; | ... | @@ -66,10 +67,7 @@ import BranchToolbar from "./BranchToolbar"; |
| 66 | import GitActionsControl from "./GitActionsControl"; | 67 | import GitActionsControl from "./GitActionsControl"; |
| 67 | import { | 68 | import { |
| 68 | isOpenFavoriteEditorShortcut, | 69 | isOpenFavoriteEditorShortcut, |
| 69 | isTerminalCloseShortcut, | 70 | resolveShortcutCommand, |
| 70 | isTerminalNewShortcut, | ||
| 71 | isTerminalSplitShortcut, | ||
| 72 | isTerminalToggleShortcut, | ||
| 73 | shortcutLabelForCommand, | 71 | shortcutLabelForCommand, |
| 74 | } from "../keybindings"; | 72 | } from "../keybindings"; |
| 75 | import ChatMarkdown from "./ChatMarkdown"; | 73 | import ChatMarkdown from "./ChatMarkdown"; |
| ... | @@ -97,6 +95,13 @@ import { CursorIcon, Icon } from "./Icons"; | ... | @@ -97,6 +95,13 @@ import { CursorIcon, Icon } from "./Icons"; |
| 97 | import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; | 95 | import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; |
| 98 | import { Badge } from "./ui/badge"; | 96 | import { Badge } from "./ui/badge"; |
| 99 | import { Command, CommandInput, CommandItem, CommandList } from "./ui/command"; | 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 | function formatMessageMeta(createdAt: string, duration: string | null): string { | 106 | function formatMessageMeta(createdAt: string, duration: string | null): string { |
| 102 | if (!duration) return formatTimestamp(createdAt); | 107 | if (!duration) return formatTimestamp(createdAt); |
| ... | @@ -117,6 +122,8 @@ const SEARCHABLE_MODEL_OPTIONS = MODEL_OPTIONS.map(({ slug, name }) => ({ | ... | @@ -117,6 +122,8 @@ const SEARCHABLE_MODEL_OPTIONS = MODEL_OPTIONS.map(({ slug, name }) => ({ |
| 117 | searchSlug: slug.toLowerCase(), | 122 | searchSlug: slug.toLowerCase(), |
| 118 | searchName: name.toLowerCase(), | 123 | searchName: name.toLowerCase(), |
| 119 | })); | 124 | })); |
| 125 | const SCRIPT_TERMINAL_COLS = 120; | ||
| 126 | const SCRIPT_TERMINAL_ROWS = 30; | ||
| 120 | 127 | ||
| 121 | function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { | 128 | function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { |
| 122 | if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50"; | 129 | if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50"; |
| ... | @@ -618,6 +625,125 @@ export default function ChatView() { | ... | @@ -618,6 +625,125 @@ export default function ChatView() { |
| 618 | }, | 625 | }, |
| 619 | [activeThread?.terminalIds.length, activeThreadId, api, dispatch], | 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 | const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => { | 748 | const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => { |
| 623 | if (mode === state.runtimeMode) return; | 749 | if (mode === state.runtimeMode) return; |
| ... | @@ -793,14 +919,17 @@ export default function ChatView() { | ... | @@ -793,14 +919,17 @@ export default function ChatView() { |
| 793 | terminalOpen: Boolean(activeThread?.terminalOpen), | 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 | event.preventDefault(); | 926 | event.preventDefault(); |
| 798 | event.stopPropagation(); | 927 | event.stopPropagation(); |
| 799 | toggleTerminalVisibility(); | 928 | toggleTerminalVisibility(); |
| 800 | return; | 929 | return; |
| 801 | } | 930 | } |
| 802 | 931 | ||
| 803 | if (isTerminalSplitShortcut(event, keybindings, { context: shortcutContext })) { | 932 | if (command === "terminal.split") { |
| 804 | event.preventDefault(); | 933 | event.preventDefault(); |
| 805 | event.stopPropagation(); | 934 | event.stopPropagation(); |
| 806 | if (!activeThread?.terminalOpen) { | 935 | if (!activeThread?.terminalOpen) { |
| ... | @@ -814,7 +943,7 @@ export default function ChatView() { | ... | @@ -814,7 +943,7 @@ export default function ChatView() { |
| 814 | return; | 943 | return; |
| 815 | } | 944 | } |
| 816 | 945 | ||
| 817 | if (isTerminalCloseShortcut(event, keybindings, { context: shortcutContext })) { | 946 | if (command === "terminal.close") { |
| 818 | event.preventDefault(); | 947 | event.preventDefault(); |
| 819 | event.stopPropagation(); | 948 | event.stopPropagation(); |
| 820 | if (!activeThread?.terminalOpen) return; | 949 | if (!activeThread?.terminalOpen) return; |
| ... | @@ -822,27 +951,39 @@ export default function ChatView() { | ... | @@ -822,27 +951,39 @@ export default function ChatView() { |
| 822 | return; | 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 | event.preventDefault(); | 972 | event.preventDefault(); |
| 827 | event.stopPropagation(); | 973 | event.stopPropagation(); |
| 828 | if (!activeThread?.terminalOpen) { | 974 | void runProjectScript(script); |
| 829 | dispatch({ | ||
| 830 | type: "SET_THREAD_TERMINAL_OPEN", | ||
| 831 | threadId: activeThreadId, | ||
| 832 | open: true, | ||
| 833 | }); | ||
| 834 | } | ||
| 835 | createNewTerminal(); | ||
| 836 | }; | 975 | }; |
| 837 | window.addEventListener("keydown", handler); | 976 | window.addEventListener("keydown", handler); |
| 838 | return () => window.removeEventListener("keydown", handler); | 977 | return () => window.removeEventListener("keydown", handler); |
| 839 | }, [ | 978 | }, [ |
| 979 | activeProject, | ||
| 840 | activeThread?.terminalOpen, | 980 | activeThread?.terminalOpen, |
| 841 | activeThread?.activeTerminalId, | 981 | activeThread?.activeTerminalId, |
| 842 | activeThreadId, | 982 | activeThreadId, |
| 843 | closeTerminal, | 983 | closeTerminal, |
| 844 | createNewTerminal, | 984 | createNewTerminal, |
| 845 | dispatch, | 985 | dispatch, |
| 986 | runProjectScript, | ||
| 846 | splitTerminal, | 987 | splitTerminal, |
| 847 | keybindings, | 988 | keybindings, |
| 848 | toggleTerminalVisibility, | 989 | toggleTerminalVisibility, |
| ... | @@ -1047,6 +1188,13 @@ export default function ChatView() { | ... | @@ -1047,6 +1188,13 @@ export default function ChatView() { |
| 1047 | branch: result.worktree.branch, | 1188 | branch: result.worktree.branch, |
| 1048 | worktreePath: result.worktree.path, | 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 | } catch (err) { | 1198 | } catch (err) { |
| 1051 | dispatch({ | 1199 | dispatch({ |
| 1052 | type: "SET_ERROR", | 1200 | type: "SET_ERROR", |
| ... | @@ -1325,10 +1473,15 @@ export default function ChatView() { | ... | @@ -1325,10 +1473,15 @@ export default function ChatView() { |
| 1325 | <ChatHeader | 1473 | <ChatHeader |
| 1326 | activeThreadTitle={activeThread.title} | 1474 | activeThreadTitle={activeThread.title} |
| 1327 | activeProjectName={activeProject?.name} | 1475 | activeProjectName={activeProject?.name} |
| 1476 | activeProjectScripts={activeProject?.scripts} | ||
| 1328 | keybindings={keybindings} | 1477 | keybindings={keybindings} |
| 1329 | api={api} | 1478 | api={api} |
| 1330 | gitCwd={gitCwd} | 1479 | gitCwd={gitCwd} |
| 1331 | diffOpen={state.diffOpen} | 1480 | diffOpen={state.diffOpen} |
| 1481 | onRunProjectScript={(script) => { | ||
| 1482 | void runProjectScript(script); | ||
| 1483 | }} | ||
| 1484 | onAddProjectScript={saveProjectScript} | ||
| 1332 | onToggleDiff={onToggleDiff} | 1485 | onToggleDiff={onToggleDiff} |
| 1333 | /> | 1486 | /> |
| 1334 | </header> | 1487 | </header> |
| ... | @@ -1652,20 +1805,26 @@ export default function ChatView() { | ... | @@ -1652,20 +1805,26 @@ export default function ChatView() { |
| 1652 | interface ChatHeaderProps { | 1805 | interface ChatHeaderProps { |
| 1653 | activeThreadTitle: string; | 1806 | activeThreadTitle: string; |
| 1654 | activeProjectName: string | undefined; | 1807 | activeProjectName: string | undefined; |
| 1808 | activeProjectScripts: ProjectScript[] | undefined; | ||
| 1655 | keybindings: ResolvedKeybindingsConfig; | 1809 | keybindings: ResolvedKeybindingsConfig; |
| 1656 | api: NativeApi | undefined; | 1810 | api: NativeApi | undefined; |
| 1657 | gitCwd: string | null; | 1811 | gitCwd: string | null; |
| 1658 | diffOpen: boolean; | 1812 | diffOpen: boolean; |
| 1813 | onRunProjectScript: (script: ProjectScript) => void; | ||
| 1814 | onAddProjectScript: (input: NewProjectScriptInput) => Promise<void>; | ||
| 1659 | onToggleDiff: () => void; | 1815 | onToggleDiff: () => void; |
| 1660 | } | 1816 | } |
| 1661 | 1817 | ||
| 1662 | const ChatHeader = memo(function ChatHeader({ | 1818 | const ChatHeader = memo(function ChatHeader({ |
| 1663 | activeThreadTitle, | 1819 | activeThreadTitle, |
| 1664 | activeProjectName, | 1820 | activeProjectName, |
| 1821 | activeProjectScripts, | ||
| 1665 | keybindings, | 1822 | keybindings, |
| 1666 | api, | 1823 | api, |
| 1667 | gitCwd, | 1824 | gitCwd, |
| 1668 | diffOpen, | 1825 | diffOpen, |
| 1826 | onRunProjectScript, | ||
| 1827 | onAddProjectScript, | ||
| 1669 | onToggleDiff, | 1828 | onToggleDiff, |
| 1670 | }: ChatHeaderProps) { | 1829 | }: ChatHeaderProps) { |
| 1671 | return ( | 1830 | return ( |
| ... | @@ -1677,6 +1836,14 @@ const ChatHeader = memo(function ChatHeader({ | ... | @@ -1677,6 +1836,14 @@ const ChatHeader = memo(function ChatHeader({ |
| 1677 | {activeProjectName && <Badge variant="outline">{activeProjectName}</Badge>} | 1836 | {activeProjectName && <Badge variant="outline">{activeProjectName}</Badge>} |
| 1678 | </div> | 1837 | </div> |
| 1679 | <div className="shrink-0 flex items-center gap-3"> | 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 | {activeProjectName && <OpenInPicker keybindings={keybindings} />} | 1847 | {activeProjectName && <OpenInPicker keybindings={keybindings} />} |
| 1681 | {activeProjectName && <GitActionsControl api={api} gitCwd={gitCwd} />} | 1848 | {activeProjectName && <GitActionsControl api={api} gitCwd={gitCwd} />} |
| 1682 | <Button | 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,6 +214,7 @@ export default function Sidebar() { |
| 214 | cwd: result.project.cwd, | 214 | cwd: result.project.cwd, |
| 215 | model: DEFAULT_MODEL, | 215 | model: DEFAULT_MODEL, |
| 216 | expanded: true, | 216 | expanded: true, |
| 217 | scripts: result.project.scripts, | ||
| 217 | }; | 218 | }; |
| 218 | const existingById = state.projects.find((p) => p.id === project.id); | 219 | const existingById = state.projects.find((p) => p.id === project.id); |
| 219 | const existingByCwd = state.projects.find((p) => p.cwd === project.cwd); | 220 | const existingByCwd = state.projects.find((p) => p.cwd === project.cwd); |
| ... | @@ -241,6 +242,7 @@ export default function Sidebar() { | ... | @@ -241,6 +242,7 @@ export default function Sidebar() { |
| 241 | cwd, | 242 | cwd, |
| 242 | model: DEFAULT_MODEL, | 243 | model: DEFAULT_MODEL, |
| 243 | expanded: true, | 244 | expanded: true, |
| 245 | scripts: [], | ||
| 244 | }; | 246 | }; |
| 245 | dispatch({ type: "ADD_PROJECT", project }); | 247 | dispatch({ type: "ADD_PROJECT", project }); |
| 246 | handleNewThread(project.id); | 248 | handleNewThread(project.id); |
apps/web/src/keybindings.test.ts+16| ... | @@ -16,6 +16,7 @@ import { | ... | @@ -16,6 +16,7 @@ import { |
| 16 | isTerminalNewShortcut, | 16 | isTerminalNewShortcut, |
| 17 | isTerminalSplitShortcut, | 17 | isTerminalSplitShortcut, |
| 18 | isTerminalToggleShortcut, | 18 | isTerminalToggleShortcut, |
| 19 | resolveShortcutCommand, | ||
| 19 | shortcutLabelForCommand, | 20 | shortcutLabelForCommand, |
| 20 | terminalNavigationShortcutData, | 21 | terminalNavigationShortcutData, |
| 21 | type ShortcutEventLike, | 22 | type ShortcutEventLike, |
| ... | @@ -339,6 +340,21 @@ describe("cross-command precedence", () => { | ... | @@ -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 | describe("formatShortcutLabel", () => { | 358 | describe("formatShortcutLabel", () => { |
| 343 | it("formats labels for macOS", () => { | 359 | it("formats labels for macOS", () => { |
| 344 | assert.strictEqual( | 360 | assert.strictEqual( |
apps/web/src/keybindings.ts+2-2| ... | @@ -100,11 +100,11 @@ function matchesCommandShortcut( | ... | @@ -100,11 +100,11 @@ function matchesCommandShortcut( |
| 100 | return resolveShortcutCommand(event, keybindings, options) === command; | 100 | return resolveShortcutCommand(event, keybindings, options) === command; |
| 101 | } | 101 | } |
| 102 | 102 | ||
| 103 | function resolveShortcutCommand( | 103 | export function resolveShortcutCommand( |
| 104 | event: ShortcutEventLike, | 104 | event: ShortcutEventLike, |
| 105 | keybindings: ResolvedKeybindingsConfig, | 105 | keybindings: ResolvedKeybindingsConfig, |
| 106 | options?: ShortcutMatchOptions, | 106 | options?: ShortcutMatchOptions, |
| 107 | ): KeybindingCommand | null { | 107 | ): string | null { |
| 108 | const platform = resolvePlatform(options); | 108 | const platform = resolvePlatform(options); |
| 109 | const context = resolveContext(options); | 109 | const context = resolveContext(options); |
| 110 | 110 |
apps/web/src/persistenceSchema.test.ts+7| ... | @@ -20,6 +20,7 @@ describe("hydratePersistedState", () => { | ... | @@ -20,6 +20,7 @@ describe("hydratePersistedState", () => { |
| 20 | cwd: "/tmp/project", | 20 | cwd: "/tmp/project", |
| 21 | model: "gpt-5.2-codex", | 21 | model: "gpt-5.2-codex", |
| 22 | expanded: true, | 22 | expanded: true, |
| 23 | scripts: [], | ||
| 23 | }, | 24 | }, |
| 24 | ], | 25 | ], |
| 25 | threads: [ | 26 | threads: [ |
| ... | @@ -69,6 +70,7 @@ describe("hydratePersistedState", () => { | ... | @@ -69,6 +70,7 @@ describe("hydratePersistedState", () => { |
| 69 | cwd: "/tmp/project", | 70 | cwd: "/tmp/project", |
| 70 | model: "gpt-5.3-codex", | 71 | model: "gpt-5.3-codex", |
| 71 | expanded: false, | 72 | expanded: false, |
| 73 | scripts: [], | ||
| 72 | }, | 74 | }, |
| 73 | ], | 75 | ], |
| 74 | threads: [ | 76 | threads: [ |
| ... | @@ -119,6 +121,7 @@ describe("hydratePersistedState", () => { | ... | @@ -119,6 +121,7 @@ describe("hydratePersistedState", () => { |
| 119 | cwd: "/tmp/project", | 121 | cwd: "/tmp/project", |
| 120 | model: "gpt-5.3-codex", | 122 | model: "gpt-5.3-codex", |
| 121 | expanded: false, | 123 | expanded: false, |
| 124 | scripts: [], | ||
| 122 | }, | 125 | }, |
| 123 | ], | 126 | ], |
| 124 | threads: [], | 127 | threads: [], |
| ... | @@ -140,6 +143,7 @@ describe("hydratePersistedState", () => { | ... | @@ -140,6 +143,7 @@ describe("hydratePersistedState", () => { |
| 140 | cwd: "/tmp/project", | 143 | cwd: "/tmp/project", |
| 141 | model: "gpt-5.3-codex", | 144 | model: "gpt-5.3-codex", |
| 142 | expanded: true, | 145 | expanded: true, |
| 146 | scripts: [], | ||
| 143 | }, | 147 | }, |
| 144 | ], | 148 | ], |
| 145 | threads: [ | 149 | threads: [ |
| ... | @@ -187,6 +191,7 @@ describe("hydratePersistedState", () => { | ... | @@ -187,6 +191,7 @@ describe("hydratePersistedState", () => { |
| 187 | cwd: "/tmp/project", | 191 | cwd: "/tmp/project", |
| 188 | model: "gpt-5.3-codex", | 192 | model: "gpt-5.3-codex", |
| 189 | expanded: true, | 193 | expanded: true, |
| 194 | scripts: [], | ||
| 190 | }, | 195 | }, |
| 191 | ], | 196 | ], |
| 192 | threads: [ | 197 | threads: [ |
| ... | @@ -231,6 +236,7 @@ describe("hydratePersistedState", () => { | ... | @@ -231,6 +236,7 @@ describe("hydratePersistedState", () => { |
| 231 | cwd: "/tmp/project", | 236 | cwd: "/tmp/project", |
| 232 | model: "gpt-5.3-codex", | 237 | model: "gpt-5.3-codex", |
| 233 | expanded: true, | 238 | expanded: true, |
| 239 | scripts: [], | ||
| 234 | }, | 240 | }, |
| 235 | ], | 241 | ], |
| 236 | threads: [ | 242 | threads: [ |
| ... | @@ -313,6 +319,7 @@ describe("toPersistedState", () => { | ... | @@ -313,6 +319,7 @@ describe("toPersistedState", () => { |
| 313 | cwd: "/tmp/project", | 319 | cwd: "/tmp/project", |
| 314 | model: "gpt-5.3-codex", | 320 | model: "gpt-5.3-codex", |
| 315 | expanded: true, | 321 | expanded: true, |
| 322 | scripts: [], | ||
| 316 | }, | 323 | }, |
| 317 | ], | 324 | ], |
| 318 | threads: [thread], | 325 | threads: [thread], |
apps/web/src/persistenceSchema.ts+3| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { z } from "zod"; | 1 | import { z } from "zod"; |
| 2 | import { projectScriptSchema } from "@t3tools/contracts"; | ||
| 2 | 3 | ||
| 3 | import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic"; | 4 | import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic"; |
| 4 | import { | 5 | import { |
| ... | @@ -19,6 +20,7 @@ const persistedProjectSchema = z.object({ | ... | @@ -19,6 +20,7 @@ const persistedProjectSchema = z.object({ |
| 19 | cwd: z.string().min(1), | 20 | cwd: z.string().min(1), |
| 20 | model: z.string().min(1), | 21 | model: z.string().min(1), |
| 21 | expanded: z.boolean(), | 22 | expanded: z.boolean(), |
| 23 | scripts: z.array(projectScriptSchema).default([]), | ||
| 22 | }); | 24 | }); |
| 23 | 25 | ||
| 24 | const persistedMessageSchema = z.object({ | 26 | const persistedMessageSchema = z.object({ |
| ... | @@ -140,6 +142,7 @@ function hydrateProject( | ... | @@ -140,6 +142,7 @@ function hydrateProject( |
| 140 | return { | 142 | return { |
| 141 | ...project, | 143 | ...project, |
| 142 | model: resolveModelSlug(maybeMigrateLegacyModel(project.model, isLegacyPayload)), | 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,6 +74,7 @@ function makeState(thread: Thread): AppState { |
| 74 | cwd: "/tmp/project", | 74 | cwd: "/tmp/project", |
| 75 | model: "gpt-5.3-codex", | 75 | model: "gpt-5.3-codex", |
| 76 | expanded: true, | 76 | expanded: true, |
| 77 | scripts: [], | ||
| 77 | }, | 78 | }, |
| 78 | ], | 79 | ], |
| 79 | threads: [thread], | 80 | threads: [thread], |
| ... | @@ -507,6 +508,7 @@ describe("store reducer thread continuity", () => { | ... | @@ -507,6 +508,7 @@ describe("store reducer thread continuity", () => { |
| 507 | cwd: "/tmp/a", | 508 | cwd: "/tmp/a", |
| 508 | model: "gpt-5.3-codex", | 509 | model: "gpt-5.3-codex", |
| 509 | expanded: false, | 510 | expanded: false, |
| 511 | scripts: [], | ||
| 510 | }, | 512 | }, |
| 511 | { | 513 | { |
| 512 | id: "project-old-b", | 514 | id: "project-old-b", |
| ... | @@ -514,6 +516,7 @@ describe("store reducer thread continuity", () => { | ... | @@ -514,6 +516,7 @@ describe("store reducer thread continuity", () => { |
| 514 | cwd: "/tmp/b", | 516 | cwd: "/tmp/b", |
| 515 | model: "gpt-5.3-codex", | 517 | model: "gpt-5.3-codex", |
| 516 | expanded: true, | 518 | expanded: true, |
| 519 | scripts: [], | ||
| 517 | }, | 520 | }, |
| 518 | ], | 521 | ], |
| 519 | threads: [ | 522 | threads: [ |
| ... | @@ -540,6 +543,7 @@ describe("store reducer thread continuity", () => { | ... | @@ -540,6 +543,7 @@ describe("store reducer thread continuity", () => { |
| 540 | cwd: "/tmp/a", | 543 | cwd: "/tmp/a", |
| 541 | model: "gpt-5.3-codex", | 544 | model: "gpt-5.3-codex", |
| 542 | expanded: true, | 545 | expanded: true, |
| 546 | scripts: [], | ||
| 543 | }, | 547 | }, |
| 544 | ], | 548 | ], |
| 545 | }); | 549 | }); |
| ... | @@ -554,6 +558,33 @@ describe("store reducer thread continuity", () => { | ... | @@ -554,6 +558,33 @@ describe("store reducer thread continuity", () => { |
| 554 | expect(next.activeThreadId).toBe("thread-a"); | 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 | it("deletes a project and all of its threads", () => { | 588 | it("deletes a project and all of its threads", () => { |
| 558 | const state: AppState = { | 589 | const state: AppState = { |
| 559 | projects: [ | 590 | projects: [ |
| ... | @@ -563,6 +594,7 @@ describe("store reducer thread continuity", () => { | ... | @@ -563,6 +594,7 @@ describe("store reducer thread continuity", () => { |
| 563 | cwd: "/tmp/one", | 594 | cwd: "/tmp/one", |
| 564 | model: "gpt-5.3-codex", | 595 | model: "gpt-5.3-codex", |
| 565 | expanded: true, | 596 | expanded: true, |
| 597 | scripts: [], | ||
| 566 | }, | 598 | }, |
| 567 | { | 599 | { |
| 568 | id: "project-2", | 600 | id: "project-2", |
| ... | @@ -570,6 +602,7 @@ describe("store reducer thread continuity", () => { | ... | @@ -570,6 +602,7 @@ describe("store reducer thread continuity", () => { |
| 570 | cwd: "/tmp/two", | 602 | cwd: "/tmp/two", |
| 571 | model: "gpt-5.3-codex", | 603 | model: "gpt-5.3-codex", |
| 572 | expanded: true, | 604 | expanded: true, |
| 605 | scripts: [], | ||
| 573 | }, | 606 | }, |
| 574 | ], | 607 | ], |
| 575 | threads: [ | 608 | threads: [ |
apps/web/src/store.ts+50| ... | @@ -17,6 +17,7 @@ import { | ... | @@ -17,6 +17,7 @@ import { |
| 17 | DEFAULT_THREAD_TERMINAL_ID, | 17 | DEFAULT_THREAD_TERMINAL_ID, |
| 18 | DEFAULT_RUNTIME_MODE, | 18 | DEFAULT_RUNTIME_MODE, |
| 19 | MAX_THREAD_TERMINAL_COUNT, | 19 | MAX_THREAD_TERMINAL_COUNT, |
| 20 | type ProjectScript, | ||
| 20 | type Project, | 21 | type Project, |
| 21 | type RuntimeMode, | 22 | type RuntimeMode, |
| 22 | type Thread, | 23 | type Thread, |
| ... | @@ -27,6 +28,7 @@ import { | ... | @@ -27,6 +28,7 @@ import { |
| 27 | 28 | ||
| 28 | type Action = | 29 | type Action = |
| 29 | | { type: "ADD_PROJECT"; project: Project } | 30 | | { type: "ADD_PROJECT"; project: Project } |
| 31 | | { type: "SET_PROJECT_SCRIPTS"; projectId: string; scripts: ProjectScript[] } | ||
| 30 | | { type: "SYNC_PROJECTS"; projects: Project[] } | 32 | | { type: "SYNC_PROJECTS"; projects: Project[] } |
| 31 | | { type: "TOGGLE_PROJECT"; projectId: string } | 33 | | { type: "TOGGLE_PROJECT"; projectId: string } |
| 32 | | { type: "DELETE_PROJECT"; projectId: string } | 34 | | { type: "DELETE_PROJECT"; projectId: string } |
| ... | @@ -163,6 +165,38 @@ function normalizeRunningTerminalIds( | ... | @@ -163,6 +165,38 @@ function normalizeRunningTerminalIds( |
| 163 | .filter((id) => id.length > 0 && validTerminalIdSet.has(id)); | 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 | function normalizeTerminalGroupIds(terminalIds: string[]): string[] { | 200 | function normalizeTerminalGroupIds(terminalIds: string[]): string[] { |
| 167 | return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; | 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,20 +460,36 @@ export function reducer(state: AppState, action: Action): AppState { |
| 426 | { | 460 | { |
| 427 | ...action.project, | 461 | ...action.project, |
| 428 | model: resolveModelSlug(action.project.model), | 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 | case "SYNC_PROJECTS": { | 478 | case "SYNC_PROJECTS": { |
| 434 | const previousByCwd = new Map( | 479 | const previousByCwd = new Map( |
| 435 | state.projects.map((project) => [project.cwd, project] as const), | 480 | state.projects.map((project) => [project.cwd, project] as const), |
| 436 | ); | 481 | ); |
| 437 | const nextProjects = action.projects.map((project) => { | 482 | const nextProjects = action.projects.map((project) => { |
| 438 | const previous = previousByCwd.get(project.cwd); | 483 | const previous = previousByCwd.get(project.cwd); |
| 484 | const scripts = | ||
| 485 | project.scripts.length > 0 | ||
| 486 | ? normalizeProjectScripts(project.scripts) | ||
| 487 | : normalizeProjectScripts(previous?.scripts ?? []); | ||
| 439 | return { | 488 | return { |
| 440 | ...project, | 489 | ...project, |
| 441 | model: resolveModelSlug(previous?.model ?? project.model), | 490 | model: resolveModelSlug(previous?.model ?? project.model), |
| 442 | expanded: previous?.expanded ?? project.expanded, | 491 | expanded: previous?.expanded ?? project.expanded, |
| 492 | scripts, | ||
| 443 | }; | 493 | }; |
| 444 | }); | 494 | }); |
| 445 | const previousProjectById = new Map( | 495 | const previousProjectById = new Map( |
apps/web/src/types.ts+7-1| ... | @@ -1,4 +1,8 @@ | ... | @@ -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 | export type SessionPhase = "disconnected" | "connecting" | "ready" | "running"; | 7 | export type SessionPhase = "disconnected" | "connecting" | "ready" | "running"; |
| 4 | export type RuntimeMode = "approval-required" | "full-access"; | 8 | export type RuntimeMode = "approval-required" | "full-access"; |
| ... | @@ -6,6 +10,7 @@ export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; | ... | @@ -6,6 +10,7 @@ export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; |
| 6 | export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280; | 10 | export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280; |
| 7 | export const DEFAULT_THREAD_TERMINAL_ID = "default"; | 11 | export const DEFAULT_THREAD_TERMINAL_ID = "default"; |
| 8 | export const MAX_THREAD_TERMINAL_COUNT = 4; | 12 | export const MAX_THREAD_TERMINAL_COUNT = 4; |
| 13 | export type ProjectScript = ContractProjectScript; | ||
| 9 | 14 | ||
| 10 | export interface ThreadTerminalGroup { | 15 | export interface ThreadTerminalGroup { |
| 11 | id: string; | 16 | id: string; |
| ... | @@ -38,6 +43,7 @@ export interface Project { | ... | @@ -38,6 +43,7 @@ export interface Project { |
| 38 | cwd: string; | 43 | cwd: string; |
| 39 | model: string; | 44 | model: string; |
| 40 | expanded: boolean; | 45 | expanded: boolean; |
| 46 | scripts: ProjectScript[]; | ||
| 41 | } | 47 | } |
| 42 | 48 | ||
| 43 | export interface Thread { | 49 | export interface Thread { |
apps/web/src/wsNativeApi.ts+1| ... | @@ -99,6 +99,7 @@ export function createWsNativeApi(): NativeApi { | ... | @@ -99,6 +99,7 @@ export function createWsNativeApi(): NativeApi { |
| 99 | add: (input) => transport.request(WS_METHODS.projectsAdd, input), | 99 | add: (input) => transport.request(WS_METHODS.projectsAdd, input), |
| 100 | remove: (input) => transport.request(WS_METHODS.projectsRemove, input), | 100 | remove: (input) => transport.request(WS_METHODS.projectsRemove, input), |
| 101 | searchEntries: (input) => transport.request(WS_METHODS.projectsSearchEntries, input), | 101 | searchEntries: (input) => transport.request(WS_METHODS.projectsSearchEntries, input), |
| 102 | updateScripts: (input) => transport.request(WS_METHODS.projectsUpdateScripts, input), | ||
| 102 | }, | 103 | }, |
| 103 | shell: { | 104 | shell: { |
| 104 | openInEditor: (cwd, editor) => | 105 | openInEditor: (cwd, editor) => |
packages/contracts/src/ipc.ts+3| ... | @@ -32,6 +32,8 @@ import type { | ... | @@ -32,6 +32,8 @@ import type { |
| 32 | ProjectSearchEntriesInput, | 32 | ProjectSearchEntriesInput, |
| 33 | ProjectSearchEntriesResult, | 33 | ProjectSearchEntriesResult, |
| 34 | ProjectRemoveInput, | 34 | ProjectRemoveInput, |
| 35 | ProjectUpdateScriptsInput, | ||
| 36 | ProjectUpdateScriptsResult, | ||
| 35 | } from "./project"; | 37 | } from "./project"; |
| 36 | import type { ServerConfig } from "./server"; | 38 | import type { ServerConfig } from "./server"; |
| 37 | import type { | 39 | import type { |
| ... | @@ -93,6 +95,7 @@ export interface NativeApi { | ... | @@ -93,6 +95,7 @@ export interface NativeApi { |
| 93 | add: (input: ProjectAddInput) => Promise<ProjectAddResult>; | 95 | add: (input: ProjectAddInput) => Promise<ProjectAddResult>; |
| 94 | remove: (input: ProjectRemoveInput) => Promise<void>; | 96 | remove: (input: ProjectRemoveInput) => Promise<void>; |
| 95 | searchEntries: (input: ProjectSearchEntriesInput) => Promise<ProjectSearchEntriesResult>; | 97 | searchEntries: (input: ProjectSearchEntriesInput) => Promise<ProjectSearchEntriesResult>; |
| 98 | updateScripts: (input: ProjectUpdateScriptsInput) => Promise<ProjectUpdateScriptsResult>; | ||
| 96 | }; | 99 | }; |
| 97 | shell: { | 100 | shell: { |
| 98 | openInEditor: (cwd: string, editor: EditorId) => Promise<void>; | 101 | openInEditor: (cwd: string, editor: EditorId) => Promise<void>; |
packages/contracts/src/keybindings.test.ts+9-1| ... | @@ -32,11 +32,19 @@ describe("keybindings contracts", () => { | ... | @@ -32,11 +32,19 @@ describe("keybindings contracts", () => { |
| 32 | assert.throws(() => | 32 | assert.throws(() => |
| 33 | keybindingRuleSchema.parse({ | 33 | keybindingRuleSchema.parse({ |
| 34 | key: "mod+j", | 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 | it("parses keybindings array payload", () => { | 48 | it("parses keybindings array payload", () => { |
| 41 | const parsed = keybindingsConfigSchema.parse([ | 49 | const parsed = keybindingsConfigSchema.parse([ |
| 42 | { key: "mod+j", command: "terminal.toggle" }, | 50 | { key: "mod+j", command: "terminal.toggle" }, |
packages/contracts/src/keybindings.ts+11-1| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | import { z } from "zod"; | 1 | import { z } from "zod"; |
| 2 | 2 | ||
| 3 | export const keybindingCommandSchema = z.enum([ | 3 | export const STATIC_KEYBINDING_COMMANDS = [ |
| 4 | "terminal.toggle", | 4 | "terminal.toggle", |
| 5 | "terminal.split", | 5 | "terminal.split", |
| 6 | "terminal.new", | 6 | "terminal.new", |
| ... | @@ -8,6 +8,16 @@ export const keybindingCommandSchema = z.enum([ | ... | @@ -8,6 +8,16 @@ export const keybindingCommandSchema = z.enum([ |
| 8 | "chat.new", | 8 | "chat.new", |
| 9 | "chat.newLocal", | 9 | "chat.newLocal", |
| 10 | "editor.openFavorite", | 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 | const keybindingValueSchema = z.string().trim().min(1).max(64); | 23 | const keybindingValueSchema = z.string().trim().min(1).max(64); |
packages/contracts/src/project.test.ts+45| ... | @@ -7,6 +7,8 @@ import { | ... | @@ -7,6 +7,8 @@ import { |
| 7 | projectSearchEntriesResultSchema, | 7 | projectSearchEntriesResultSchema, |
| 8 | projectListResultSchema, | 8 | projectListResultSchema, |
| 9 | projectRemoveInputSchema, | 9 | projectRemoveInputSchema, |
| 10 | projectUpdateScriptsInputSchema, | ||
| 11 | projectUpdateScriptsResultSchema, | ||
| 10 | } from "./project"; | 12 | } from "./project"; |
| 11 | 13 | ||
| 12 | describe("project contracts", () => { | 14 | describe("project contracts", () => { |
| ... | @@ -16,6 +18,7 @@ describe("project contracts", () => { | ... | @@ -16,6 +18,7 @@ describe("project contracts", () => { |
| 16 | id: "project-1", | 18 | id: "project-1", |
| 17 | cwd: "/tmp/project", | 19 | cwd: "/tmp/project", |
| 18 | name: "project", | 20 | name: "project", |
| 21 | scripts: [], | ||
| 19 | createdAt: "2026-02-10T00:00:00.000Z", | 22 | createdAt: "2026-02-10T00:00:00.000Z", |
| 20 | updatedAt: "2026-02-10T00:00:00.000Z", | 23 | updatedAt: "2026-02-10T00:00:00.000Z", |
| 21 | }, | 24 | }, |
| ... | @@ -36,6 +39,7 @@ describe("project contracts", () => { | ... | @@ -36,6 +39,7 @@ describe("project contracts", () => { |
| 36 | id: "project-1", | 39 | id: "project-1", |
| 37 | cwd: "/tmp/project", | 40 | cwd: "/tmp/project", |
| 38 | name: "project", | 41 | name: "project", |
| 42 | scripts: [], | ||
| 39 | createdAt: "2026-02-10T00:00:00.000Z", | 43 | createdAt: "2026-02-10T00:00:00.000Z", |
| 40 | updatedAt: "2026-02-10T00:00:00.000Z", | 44 | updatedAt: "2026-02-10T00:00:00.000Z", |
| 41 | }, | 45 | }, |
| ... | @@ -82,4 +86,45 @@ describe("project contracts", () => { | ... | @@ -82,4 +86,45 @@ describe("project contracts", () => { |
| 82 | expect(parsed.entries).toHaveLength(2); | 86 | expect(parsed.entries).toHaveLength(2); |
| 83 | expect(parsed.entries[0]?.kind).toBe("directory"); | 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,9 +1,31 @@ |
| 1 | import { z } from "zod"; | 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 | export const projectRecordSchema = z.object({ | 24 | export const projectRecordSchema = z.object({ |
| 4 | id: z.string().min(1), | 25 | id: z.string().min(1), |
| 5 | cwd: z.string().min(1), | 26 | cwd: z.string().min(1), |
| 6 | name: z.string().min(1), | 27 | name: z.string().min(1), |
| 28 | scripts: projectScriptsSchema.default([]), | ||
| 7 | createdAt: z.string().datetime(), | 29 | createdAt: z.string().datetime(), |
| 8 | updatedAt: z.string().datetime(), | 30 | updatedAt: z.string().datetime(), |
| 9 | }); | 31 | }); |
| ... | @@ -44,6 +66,17 @@ export const projectSearchEntriesResultSchema = z.object({ | ... | @@ -44,6 +66,17 @@ export const projectSearchEntriesResultSchema = z.object({ |
| 44 | truncated: z.boolean(), | 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 | export type ProjectRecord = z.infer<typeof projectRecordSchema>; | 80 | export type ProjectRecord = z.infer<typeof projectRecordSchema>; |
| 48 | export type ProjectListResult = z.infer<typeof projectListResultSchema>; | 81 | export type ProjectListResult = z.infer<typeof projectListResultSchema>; |
| 49 | export type ProjectAddInput = z.input<typeof projectAddInputSchema>; | 82 | export type ProjectAddInput = z.input<typeof projectAddInputSchema>; |
| ... | @@ -53,3 +86,5 @@ export type ProjectSearchEntriesInput = z.input<typeof projectSearchEntriesInput | ... | @@ -53,3 +86,5 @@ export type ProjectSearchEntriesInput = z.input<typeof projectSearchEntriesInput |
| 53 | export type ProjectEntryKind = z.infer<typeof projectEntryKindSchema>; | 86 | export type ProjectEntryKind = z.infer<typeof projectEntryKindSchema>; |
| 54 | export type ProjectEntry = z.infer<typeof projectEntrySchema>; | 87 | export type ProjectEntry = z.infer<typeof projectEntrySchema>; |
| 55 | export type ProjectSearchEntriesResult = z.infer<typeof projectSearchEntriesResultSchema>; | 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,6 +16,7 @@ export const WS_METHODS = { |
| 16 | projectsAdd: "projects.add", | 16 | projectsAdd: "projects.add", |
| 17 | projectsRemove: "projects.remove", | 17 | projectsRemove: "projects.remove", |
| 18 | projectsSearchEntries: "projects.searchEntries", | 18 | projectsSearchEntries: "projects.searchEntries", |
| 19 | projectsUpdateScripts: "projects.updateScripts", | ||
| 19 | 20 | ||
| 20 | // Shell methods | 21 | // Shell methods |
| 21 | shellOpenInEditor: "shell.openInEditor", | 22 | shellOpenInEditor: "shell.openInEditor", |