| author | |
| committer | |
| log | 648a6ac372bb32e7f6a35b73eb23e444f3c99ef3 |
| tree | b6098dfb978a188fe0e4b29f1035ac3994c2f4b6 |
| parent | 3537d477027565cd0d2c1661ff2e41d8f20ea166 |
- add server `upsertKeybinding` flow to write `~/.t3/keybindings.json` and refresh cached config
- wire project script create/edit to save optional keybindings via the WS API
- improve script action UI with edit controls, icon picker, and preferred-script recall
- add coverage for keybinding upsert behavior in server and websocket tests14 files changed, 779 insertions(+), 127 deletions(-)
apps/server/src/keybindings.test.ts+80-2| ... | @@ -1,8 +1,34 @@ | ... | @@ -1,8 +1,34 @@ |
| 1 | import { assert, describe, it } from "vitest"; | 1 | import fs from "node:fs"; |
| 2 | import os from "node:os"; | ||
| 3 | import path from "node:path"; | ||
| 4 | import { assert, afterEach, describe, expect, it, vi } from "vitest"; | ||
| 2 | 5 | ||
| 3 | import { compileResolvedKeybindingRule, parseKeybindingShortcut } from "./keybindings"; | 6 | import { |
| 7 | compileResolvedKeybindingRule, | ||
| 8 | parseKeybindingShortcut, | ||
| 9 | upsertKeybindingRule, | ||
| 10 | } from "./keybindings"; | ||
| 4 | 11 | ||
| 5 | describe("server keybindings", () => { | 12 | describe("server keybindings", () => { |
| 13 | const tempDirs: string[] = []; | ||
| 14 | const logger = { | ||
| 15 | warn: vi.fn(), | ||
| 16 | }; | ||
| 17 | |||
| 18 | function makeTempDir(prefix: string): string { | ||
| 19 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); | ||
| 20 | tempDirs.push(dir); | ||
| 21 | return dir; | ||
| 22 | } | ||
| 23 | |||
| 24 | afterEach(() => { | ||
| 25 | vi.restoreAllMocks(); | ||
| 26 | logger.warn.mockReset(); | ||
| 27 | for (const dir of tempDirs.splice(0, tempDirs.length)) { | ||
| 28 | fs.rmSync(dir, { recursive: true, force: true }); | ||
| 29 | } | ||
| 30 | }); | ||
| 31 | |||
| 6 | it("parses shortcuts including plus key", () => { | 32 | it("parses shortcuts including plus key", () => { |
| 7 | assert.deepEqual(parseKeybindingShortcut("mod+j"), { | 33 | assert.deepEqual(parseKeybindingShortcut("mod+j"), { |
| 8 | key: "j", | 34 | key: "j", |
| ... | @@ -66,4 +92,56 @@ describe("server keybindings", () => { | ... | @@ -66,4 +92,56 @@ describe("server keybindings", () => { |
| 66 | }), | 92 | }), |
| 67 | ); | 93 | ); |
| 68 | }); | 94 | }); |
| 95 | |||
| 96 | it("upserts custom keybindings to ~/.t3/keybindings.json", () => { | ||
| 97 | const fakeHome = makeTempDir("t3code-keybindings-upsert-"); | ||
| 98 | const configDir = path.join(fakeHome, ".t3"); | ||
| 99 | fs.mkdirSync(configDir, { recursive: true }); | ||
| 100 | const configPath = path.join(configDir, "keybindings.json"); | ||
| 101 | fs.writeFileSync( | ||
| 102 | configPath, | ||
| 103 | JSON.stringify([{ key: "mod+j", command: "terminal.toggle" }]), | ||
| 104 | "utf8", | ||
| 105 | ); | ||
| 106 | vi.spyOn(os, "homedir").mockReturnValue(fakeHome); | ||
| 107 | |||
| 108 | const resolved = upsertKeybindingRule(logger, { | ||
| 109 | key: "mod+shift+r", | ||
| 110 | command: "script.run-tests.run", | ||
| 111 | }); | ||
| 112 | const persisted = JSON.parse(fs.readFileSync(configPath, "utf8")) as Array<{ | ||
| 113 | key: string; | ||
| 114 | command: string; | ||
| 115 | }>; | ||
| 116 | |||
| 117 | expect(persisted).toEqual([ | ||
| 118 | { key: "mod+j", command: "terminal.toggle" }, | ||
| 119 | { key: "mod+shift+r", command: "script.run-tests.run" }, | ||
| 120 | ]); | ||
| 121 | expect(resolved.some((entry) => entry.command === "script.run-tests.run")).toBe(true); | ||
| 122 | }); | ||
| 123 | |||
| 124 | it("replaces existing custom keybinding for the same command", () => { | ||
| 125 | const fakeHome = makeTempDir("t3code-keybindings-upsert-replace-"); | ||
| 126 | const configDir = path.join(fakeHome, ".t3"); | ||
| 127 | fs.mkdirSync(configDir, { recursive: true }); | ||
| 128 | const configPath = path.join(configDir, "keybindings.json"); | ||
| 129 | fs.writeFileSync( | ||
| 130 | configPath, | ||
| 131 | JSON.stringify([{ key: "mod+r", command: "script.run-tests.run" }]), | ||
| 132 | "utf8", | ||
| 133 | ); | ||
| 134 | vi.spyOn(os, "homedir").mockReturnValue(fakeHome); | ||
| 135 | |||
| 136 | upsertKeybindingRule(logger, { | ||
| 137 | key: "mod+shift+r", | ||
| 138 | command: "script.run-tests.run", | ||
| 139 | }); | ||
| 140 | |||
| 141 | const persisted = JSON.parse(fs.readFileSync(configPath, "utf8")) as Array<{ | ||
| 142 | key: string; | ||
| 143 | command: string; | ||
| 144 | }>; | ||
| 145 | expect(persisted).toEqual([{ key: "mod+shift+r", command: "script.run-tests.run" }]); | ||
| 146 | }); | ||
| 69 | }); | 147 | }); |
apps/server/src/keybindings.ts+80-40| ... | @@ -274,30 +274,15 @@ function compileDefaultKeybindings(): ResolvedKeybindingsConfig { | ... | @@ -274,30 +274,15 @@ function compileDefaultKeybindings(): ResolvedKeybindingsConfig { |
| 274 | } | 274 | } |
| 275 | 275 | ||
| 276 | const DEFAULT_RESOLVED_KEYBINDINGS = compileDefaultKeybindings(); | 276 | const DEFAULT_RESOLVED_KEYBINDINGS = compileDefaultKeybindings(); |
| 277 | const KEYBINDINGS_CONFIG_PATH = path.join(".t3", "keybindings.json"); | ||
| 278 | const MAX_KEYBINDINGS = 256; | ||
| 277 | 279 | ||
| 278 | function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): ResolvedKeybindingsConfig { | 280 | function resolveKeybindingsConfigPath(): string { |
| 279 | if (custom.length === 0) { | 281 | return path.join(os.homedir(), KEYBINDINGS_CONFIG_PATH); |
| 280 | return [...DEFAULT_RESOLVED_KEYBINDINGS]; | ||
| 281 | } | ||
| 282 | |||
| 283 | const overriddenCommands = new Set(custom.map((binding) => binding.command)); | ||
| 284 | const retainedDefaults = DEFAULT_RESOLVED_KEYBINDINGS.filter( | ||
| 285 | (binding) => !overriddenCommands.has(binding.command), | ||
| 286 | ); | ||
| 287 | const merged = [...retainedDefaults, ...custom]; | ||
| 288 | |||
| 289 | if (merged.length <= 256) { | ||
| 290 | return merged; | ||
| 291 | } | ||
| 292 | |||
| 293 | // Keep the latest rules when the config exceeds max size; later rules have higher precedence. | ||
| 294 | return merged.slice(-256); | ||
| 295 | } | 282 | } |
| 296 | 283 | ||
| 297 | export function loadResolvedKeybindingsConfig( | 284 | function loadCustomKeybindingsConfig(logger: KeybindingsLogger): KeybindingsConfig { |
| 298 | logger: KeybindingsLogger, | 285 | const configPath = resolveKeybindingsConfigPath(); |
| 299 | ): ResolvedKeybindingsConfig { | ||
| 300 | const configPath = path.join(os.homedir(), ".t3", "keybindings.json"); | ||
| 301 | try { | 286 | try { |
| 302 | const raw = fs.readFileSync(configPath, "utf8"); | 287 | const raw = fs.readFileSync(configPath, "utf8"); |
| 303 | const parsed = JSON.parse(raw) as unknown; | 288 | const parsed = JSON.parse(raw) as unknown; |
| ... | @@ -305,20 +290,19 @@ export function loadResolvedKeybindingsConfig( | ... | @@ -305,20 +290,19 @@ export function loadResolvedKeybindingsConfig( |
| 305 | logger.warn("ignoring keybindings config with unsupported format; expected array", { | 290 | logger.warn("ignoring keybindings config with unsupported format; expected array", { |
| 306 | path: configPath, | 291 | path: configPath, |
| 307 | }); | 292 | }); |
| 308 | return [...DEFAULT_RESOLVED_KEYBINDINGS]; | 293 | return []; |
| 309 | } | 294 | } |
| 310 | 295 | ||
| 311 | const sanitized: ResolvedKeybindingsConfig = []; | 296 | const sanitized: KeybindingsConfig = []; |
| 312 | let invalidEntries = 0; | 297 | let invalidEntries = 0; |
| 313 | for (const entry of parsed) { | 298 | for (const entry of parsed) { |
| 314 | const result = keybindingRuleSchema.safeParse(entry); | 299 | const result = keybindingRuleSchema.safeParse(entry); |
| 315 | if (result.success) { | 300 | if (result.success) { |
| 316 | const compiled = compileResolvedKeybindingRule(result.data); | 301 | if (!compileResolvedKeybindingRule(result.data)) { |
| 317 | if (!compiled) { | ||
| 318 | invalidEntries += 1; | 302 | invalidEntries += 1; |
| 319 | continue; | 303 | continue; |
| 320 | } | 304 | } |
| 321 | sanitized.push(compiled); | 305 | sanitized.push(result.data); |
| 322 | continue; | 306 | continue; |
| 323 | } | 307 | } |
| 324 | invalidEntries += 1; | 308 | invalidEntries += 1; |
| ... | @@ -330,26 +314,82 @@ export function loadResolvedKeybindingsConfig( | ... | @@ -330,26 +314,82 @@ export function loadResolvedKeybindingsConfig( |
| 330 | totalEntries: parsed.length, | 314 | totalEntries: parsed.length, |
| 331 | }); | 315 | }); |
| 332 | } | 316 | } |
| 333 | const overriddenCommands = new Set(sanitized.map((entry) => entry.command)); | 317 | return sanitized; |
| 334 | const mergedBeforeCapLength = | ||
| 335 | DEFAULT_RESOLVED_KEYBINDINGS.filter((binding) => !overriddenCommands.has(binding.command)) | ||
| 336 | .length + sanitized.length; | ||
| 337 | const merged = mergeWithDefaultKeybindings(sanitized); | ||
| 338 | if (mergedBeforeCapLength > 256) { | ||
| 339 | logger.warn("truncating merged keybindings config to max entries", { | ||
| 340 | path: configPath, | ||
| 341 | maxEntries: 256, | ||
| 342 | }); | ||
| 343 | } | ||
| 344 | return merged; | ||
| 345 | } catch (error) { | 318 | } catch (error) { |
| 346 | if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { | 319 | if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { |
| 347 | return [...DEFAULT_RESOLVED_KEYBINDINGS]; | 320 | return []; |
| 348 | } | 321 | } |
| 349 | logger.warn("ignoring malformed keybindings config", { | 322 | logger.warn("ignoring malformed keybindings config", { |
| 350 | path: configPath, | 323 | path: configPath, |
| 351 | error: error instanceof Error ? error.message : String(error), | 324 | error: error instanceof Error ? error.message : String(error), |
| 352 | }); | 325 | }); |
| 326 | return []; | ||
| 327 | } | ||
| 328 | } | ||
| 329 | |||
| 330 | function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): ResolvedKeybindingsConfig { | ||
| 331 | if (custom.length === 0) { | ||
| 332 | return [...DEFAULT_RESOLVED_KEYBINDINGS]; | ||
| 333 | } | ||
| 334 | |||
| 335 | const overriddenCommands = new Set(custom.map((binding) => binding.command)); | ||
| 336 | const retainedDefaults = DEFAULT_RESOLVED_KEYBINDINGS.filter( | ||
| 337 | (binding) => !overriddenCommands.has(binding.command), | ||
| 338 | ); | ||
| 339 | const merged = [...retainedDefaults, ...custom]; | ||
| 340 | |||
| 341 | if (merged.length <= MAX_KEYBINDINGS) { | ||
| 342 | return merged; | ||
| 343 | } | ||
| 344 | |||
| 345 | // Keep the latest rules when the config exceeds max size; later rules have higher precedence. | ||
| 346 | return merged.slice(-MAX_KEYBINDINGS); | ||
| 347 | } | ||
| 348 | |||
| 349 | export function loadResolvedKeybindingsConfig( | ||
| 350 | logger: KeybindingsLogger, | ||
| 351 | ): ResolvedKeybindingsConfig { | ||
| 352 | const customConfig = loadCustomKeybindingsConfig(logger); | ||
| 353 | const compiledCustomConfig = compileResolvedKeybindingsConfig(customConfig); | ||
| 354 | const overriddenCommands = new Set(compiledCustomConfig.map((entry) => entry.command)); | ||
| 355 | const mergedBeforeCapLength = | ||
| 356 | DEFAULT_RESOLVED_KEYBINDINGS.filter((binding) => !overriddenCommands.has(binding.command)) | ||
| 357 | .length + compiledCustomConfig.length; | ||
| 358 | const merged = mergeWithDefaultKeybindings(compiledCustomConfig); | ||
| 359 | if (mergedBeforeCapLength > MAX_KEYBINDINGS) { | ||
| 360 | logger.warn("truncating merged keybindings config to max entries", { | ||
| 361 | path: resolveKeybindingsConfigPath(), | ||
| 362 | maxEntries: MAX_KEYBINDINGS, | ||
| 363 | }); | ||
| 364 | } | ||
| 365 | return merged; | ||
| 366 | } | ||
| 367 | |||
| 368 | export function upsertKeybindingRule( | ||
| 369 | logger: KeybindingsLogger, | ||
| 370 | rawRule: unknown, | ||
| 371 | ): ResolvedKeybindingsConfig { | ||
| 372 | const rule = keybindingRuleSchema.parse(rawRule); | ||
| 373 | if (!compileResolvedKeybindingRule(rule)) { | ||
| 374 | throw new Error(`Invalid keybinding shortcut: "${rule.key}"`); | ||
| 375 | } | ||
| 376 | |||
| 377 | const configPath = resolveKeybindingsConfigPath(); | ||
| 378 | const customConfig = loadCustomKeybindingsConfig(logger); | ||
| 379 | const nextConfig = [...customConfig.filter((entry) => entry.command !== rule.command), rule]; | ||
| 380 | const cappedConfig = | ||
| 381 | nextConfig.length > MAX_KEYBINDINGS ? nextConfig.slice(-MAX_KEYBINDINGS) : nextConfig; | ||
| 382 | |||
| 383 | if (nextConfig.length > MAX_KEYBINDINGS) { | ||
| 384 | logger.warn("truncating keybindings config to max entries", { | ||
| 385 | path: configPath, | ||
| 386 | maxEntries: MAX_KEYBINDINGS, | ||
| 387 | }); | ||
| 353 | } | 388 | } |
| 354 | return [...DEFAULT_RESOLVED_KEYBINDINGS]; | 389 | |
| 390 | fs.mkdirSync(path.dirname(configPath), { recursive: true }); | ||
| 391 | fs.writeFileSync(configPath, `${JSON.stringify(cappedConfig, null, 2)}\n`, "utf8"); | ||
| 392 | |||
| 393 | const compiledCustomConfig = compileResolvedKeybindingsConfig(cappedConfig); | ||
| 394 | return mergeWithDefaultKeybindings(compiledCustomConfig); | ||
| 355 | } | 395 | } |
apps/server/src/wsServer.test.ts+53| ... | @@ -491,6 +491,59 @@ describe("WebSocket Server", () => { | ... | @@ -491,6 +491,59 @@ describe("WebSocket Server", () => { |
| 491 | expect(secondResponse.result).toEqual(firstResponse.result); | 491 | expect(secondResponse.result).toEqual(firstResponse.result); |
| 492 | }); | 492 | }); |
| 493 | 493 | ||
| 494 | it("upserts keybinding rules and updates cached server config", async () => { | ||
| 495 | const fakeHome = makeTempDir("t3code-home-upsert-keybinding-"); | ||
| 496 | const configDir = path.join(fakeHome, ".t3"); | ||
| 497 | fs.mkdirSync(configDir, { recursive: true }); | ||
| 498 | const keybindingsPath = path.join(configDir, "keybindings.json"); | ||
| 499 | fs.writeFileSync( | ||
| 500 | keybindingsPath, | ||
| 501 | JSON.stringify([{ key: "mod+j", command: "terminal.toggle" }]), | ||
| 502 | "utf8", | ||
| 503 | ); | ||
| 504 | vi.spyOn(os, "homedir").mockReturnValue(fakeHome); | ||
| 505 | |||
| 506 | server = createTestServer({ cwd: "/my/workspace" }); | ||
| 507 | await server.start(); | ||
| 508 | const addr = server.httpServer.address(); | ||
| 509 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | ||
| 510 | |||
| 511 | const ws = await connectWs(port); | ||
| 512 | connections.push(ws); | ||
| 513 | await waitForMessage(ws); | ||
| 514 | |||
| 515 | const upsertResponse = await sendRequest(ws, WS_METHODS.serverUpsertKeybinding, { | ||
| 516 | key: "mod+shift+r", | ||
| 517 | command: "script.run-tests.run", | ||
| 518 | }); | ||
| 519 | expect(upsertResponse.error).toBeUndefined(); | ||
| 520 | expect(upsertResponse.result).toEqual({ | ||
| 521 | keybindings: mergeWithDefaultsForTest([ | ||
| 522 | { key: "mod+j", command: "terminal.toggle" }, | ||
| 523 | { key: "mod+shift+r", command: "script.run-tests.run" }, | ||
| 524 | ]), | ||
| 525 | }); | ||
| 526 | |||
| 527 | const persistedConfig = JSON.parse(fs.readFileSync(keybindingsPath, "utf8")) as Array<{ | ||
| 528 | key: string; | ||
| 529 | command: string; | ||
| 530 | }>; | ||
| 531 | expect(persistedConfig).toEqual([ | ||
| 532 | { key: "mod+j", command: "terminal.toggle" }, | ||
| 533 | { key: "mod+shift+r", command: "script.run-tests.run" }, | ||
| 534 | ]); | ||
| 535 | |||
| 536 | const configResponse = await sendRequest(ws, WS_METHODS.serverGetConfig); | ||
| 537 | expect(configResponse.error).toBeUndefined(); | ||
| 538 | expect(configResponse.result).toEqual({ | ||
| 539 | cwd: "/my/workspace", | ||
| 540 | keybindings: mergeWithDefaultsForTest([ | ||
| 541 | { key: "mod+j", command: "terminal.toggle" }, | ||
| 542 | { key: "mod+shift+r", command: "script.run-tests.run" }, | ||
| 543 | ]), | ||
| 544 | }); | ||
| 545 | }); | ||
| 546 | |||
| 494 | it("warns and ignores unsupported keybindings config format", async () => { | 547 | it("warns and ignores unsupported keybindings config format", async () => { |
| 495 | const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); | 548 | const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); |
| 496 | const fakeHome = makeTempDir("t3code-home-unsupported-format-"); | 549 | const fakeHome = makeTempDir("t3code-home-unsupported-format-"); |
apps/server/src/wsServer.ts+8-2| ... | @@ -31,7 +31,7 @@ import { | ... | @@ -31,7 +31,7 @@ import { |
| 31 | removeGitWorktree, | 31 | removeGitWorktree, |
| 32 | } from "./git"; | 32 | } from "./git"; |
| 33 | import { TerminalManager } from "./terminalManager"; | 33 | import { TerminalManager } from "./terminalManager"; |
| 34 | import { loadResolvedKeybindingsConfig } from "./keybindings"; | 34 | import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings"; |
| 35 | import { searchWorkspaceEntries } from "./workspaceEntries"; | 35 | import { searchWorkspaceEntries } from "./workspaceEntries"; |
| 36 | 36 | ||
| 37 | const MIME_TYPES: Record<string, string> = { | 37 | const MIME_TYPES: Record<string, string> = { |
| ... | @@ -92,7 +92,7 @@ export function createServer(options: ServerOptions) { | ... | @@ -92,7 +92,7 @@ export function createServer(options: ServerOptions) { |
| 92 | const logger = createLogger("ws"); | 92 | const logger = createLogger("ws"); |
| 93 | const logWebSocketEvents = | 93 | const logWebSocketEvents = |
| 94 | explicitLogWsEvents ?? parseBooleanEnv(process.env.T3CODE_LOG_WS_EVENTS) ?? Boolean(devUrl); | 94 | explicitLogWsEvents ?? parseBooleanEnv(process.env.T3CODE_LOG_WS_EVENTS) ?? Boolean(devUrl); |
| 95 | const keybindingsConfig = loadResolvedKeybindingsConfig(logger); | 95 | let keybindingsConfig = loadResolvedKeybindingsConfig(logger); |
| 96 | 96 | ||
| 97 | function logOutgoingPush(push: WsPush, recipients: number) { | 97 | function logOutgoingPush(push: WsPush, recipients: number) { |
| 98 | if (!logWebSocketEvents) return; | 98 | if (!logWebSocketEvents) return; |
| ... | @@ -423,6 +423,12 @@ export function createServer(options: ServerOptions) { | ... | @@ -423,6 +423,12 @@ export function createServer(options: ServerOptions) { |
| 423 | keybindings: keybindingsConfig, | 423 | keybindings: keybindingsConfig, |
| 424 | }; | 424 | }; |
| 425 | 425 | ||
| 426 | case WS_METHODS.serverUpsertKeybinding: | ||
| 427 | keybindingsConfig = upsertKeybindingRule(logger, request.params); | ||
| 428 | return { | ||
| 429 | keybindings: keybindingsConfig, | ||
| 430 | }; | ||
| 431 | |||
| 426 | default: | 432 | default: |
| 427 | throw new Error(`Unknown method: ${request.method}`); | 433 | throw new Error(`Unknown method: ${request.method}`); |
| 428 | } | 434 | } |
apps/web/src/components/ChatView.tsx+176-9| ... | @@ -31,7 +31,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | ... | @@ -31,7 +31,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 31 | import { useDebouncedValue } from "@tanstack/react-pacer/debouncer"; | 31 | import { useDebouncedValue } from "@tanstack/react-pacer/debouncer"; |
| 32 | import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; | 32 | import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; |
| 33 | import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; | 33 | import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; |
| 34 | import { serverConfigQueryOptions } from "~/lib/serverReactQuery"; | 34 | import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; |
| 35 | 35 | ||
| 36 | import { isElectron } from "../env"; | 36 | import { isElectron } from "../env"; |
| 37 | import { buildBootstrapInput } from "../historyBootstrap"; | 37 | import { buildBootstrapInput } from "../historyBootstrap"; |
| ... | @@ -97,8 +97,10 @@ import { Badge } from "./ui/badge"; | ... | @@ -97,8 +97,10 @@ import { Badge } from "./ui/badge"; |
| 97 | import { Command, CommandInput, CommandItem, CommandList } from "./ui/command"; | 97 | import { Command, CommandInput, CommandItem, CommandList } from "./ui/command"; |
| 98 | import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl"; | 98 | import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl"; |
| 99 | import { | 99 | import { |
| 100 | commandForProjectScript, | ||
| 100 | injectEnvIntoShellCommand, | 101 | injectEnvIntoShellCommand, |
| 101 | nextProjectScriptId, | 102 | nextProjectScriptId, |
| 103 | projectScriptRuntimeEnv, | ||
| 102 | projectScriptIdFromCommand, | 104 | projectScriptIdFromCommand, |
| 103 | setupProjectScript, | 105 | setupProjectScript, |
| 104 | } from "~/projectScripts"; | 106 | } from "~/projectScripts"; |
| ... | @@ -109,6 +111,7 @@ function formatMessageMeta(createdAt: string, duration: string | null): string { | ... | @@ -109,6 +111,7 @@ function formatMessageMeta(createdAt: string, duration: string | null): string { |
| 109 | } | 111 | } |
| 110 | 112 | ||
| 111 | const LAST_EDITOR_KEY = "t3code:last-editor"; | 113 | const LAST_EDITOR_KEY = "t3code:last-editor"; |
| 114 | const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; | ||
| 112 | const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; | 115 | const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; |
| 113 | const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; | 116 | const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; |
| 114 | const IMAGE_ONLY_BOOTSTRAP_PROMPT = | 117 | const IMAGE_ONLY_BOOTSTRAP_PROMPT = |
| ... | @@ -125,6 +128,24 @@ const SEARCHABLE_MODEL_OPTIONS = MODEL_OPTIONS.map(({ slug, name }) => ({ | ... | @@ -125,6 +128,24 @@ const SEARCHABLE_MODEL_OPTIONS = MODEL_OPTIONS.map(({ slug, name }) => ({ |
| 125 | const SCRIPT_TERMINAL_COLS = 120; | 128 | const SCRIPT_TERMINAL_COLS = 120; |
| 126 | const SCRIPT_TERMINAL_ROWS = 30; | 129 | const SCRIPT_TERMINAL_ROWS = 30; |
| 127 | 130 | ||
| 131 | function readLastInvokedScriptByProjectFromStorage(): Record<string, string> { | ||
| 132 | const stored = localStorage.getItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY); | ||
| 133 | if (!stored) return {}; | ||
| 134 | |||
| 135 | try { | ||
| 136 | const parsed: unknown = JSON.parse(stored); | ||
| 137 | if (!parsed || typeof parsed !== "object") return {}; | ||
| 138 | return Object.fromEntries( | ||
| 139 | Object.entries(parsed).filter( | ||
| 140 | (entry): entry is [string, string] => | ||
| 141 | typeof entry[0] === "string" && typeof entry[1] === "string", | ||
| 142 | ), | ||
| 143 | ); | ||
| 144 | } catch { | ||
| 145 | return {}; | ||
| 146 | } | ||
| 147 | } | ||
| 148 | |||
| 128 | function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { | 149 | function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { |
| 129 | if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50"; | 150 | if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50"; |
| 130 | if (tone === "tool") return "text-muted-foreground/70"; | 151 | if (tone === "tool") return "text-muted-foreground/70"; |
| ... | @@ -330,6 +351,9 @@ export default function ChatView() { | ... | @@ -330,6 +351,9 @@ export default function ChatView() { |
| 330 | const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); | 351 | const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); |
| 331 | const [composerCursor, setComposerCursor] = useState(0); | 352 | const [composerCursor, setComposerCursor] = useState(0); |
| 332 | const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null); | 353 | const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null); |
| 354 | const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useState< | ||
| 355 | Record<string, string> | ||
| 356 | >(() => readLastInvokedScriptByProjectFromStorage()); | ||
| 333 | const messagesScrollRef = useRef<HTMLDivElement>(null); | 357 | const messagesScrollRef = useRef<HTMLDivElement>(null); |
| 334 | const messagesEndRef = useRef<HTMLDivElement>(null); | 358 | const messagesEndRef = useRef<HTMLDivElement>(null); |
| 335 | const shouldAutoScrollRef = useRef(true); | 359 | const shouldAutoScrollRef = useRef(true); |
| ... | @@ -635,6 +659,10 @@ export default function ChatView() { | ... | @@ -635,6 +659,10 @@ export default function ChatView() { |
| 635 | }, | 659 | }, |
| 636 | ) => { | 660 | ) => { |
| 637 | if (!api || !activeThreadId || !activeProject || !activeThread) return; | 661 | if (!api || !activeThreadId || !activeProject || !activeThread) return; |
| 662 | setLastInvokedScriptByProjectId((current) => { | ||
| 663 | if (current[activeProject.id] === script.id) return current; | ||
| 664 | return { ...current, [activeProject.id]: script.id }; | ||
| 665 | }); | ||
| 638 | const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd; | 666 | const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd; |
| 639 | const baseTerminalId = activeThread.activeTerminalId; | 667 | const baseTerminalId = activeThread.activeTerminalId; |
| 640 | const isBaseTerminalBusy = activeThread.runningTerminalIds.includes(baseTerminalId); | 668 | const isBaseTerminalBusy = activeThread.runningTerminalIds.includes(baseTerminalId); |
| ... | @@ -663,12 +691,20 @@ export default function ChatView() { | ... | @@ -663,12 +691,20 @@ export default function ChatView() { |
| 663 | } | 691 | } |
| 664 | setTerminalFocusRequestId((value) => value + 1); | 692 | setTerminalFocusRequestId((value) => value + 1); |
| 665 | 693 | ||
| 666 | const scriptEnv = | 694 | const resolvedCommand = injectEnvIntoShellCommand( |
| 667 | options?.env ?? | 695 | script.command, |
| 668 | (script.runOnWorktreeCreate ? { T3CODE_PROJECT_ROOT: activeProject.cwd } : undefined); | 696 | projectScriptRuntimeEnv({ |
| 669 | const resolvedCommand = scriptEnv | 697 | project: { |
| 670 | ? injectEnvIntoShellCommand(script.command, scriptEnv) | 698 | id: activeProject.id, |
| 671 | : script.command; | 699 | name: activeProject.name, |
| 700 | cwd: activeProject.cwd, | ||
| 701 | }, | ||
| 702 | script, | ||
| 703 | threadId: activeThreadId, | ||
| 704 | worktreePath: activeThread.worktreePath ?? null, | ||
| 705 | extraEnv: options?.env, | ||
| 706 | }), | ||
| 707 | ); | ||
| 672 | 708 | ||
| 673 | try { | 709 | try { |
| 674 | await api.terminal.open({ | 710 | await api.terminal.open({ |
| ... | @@ -723,17 +759,124 @@ export default function ChatView() { | ... | @@ -723,17 +759,124 @@ export default function ChatView() { |
| 723 | }); | 759 | }); |
| 724 | 760 | ||
| 725 | if (!isElectron || !api) return; | 761 | if (!isElectron || !api) return; |
| 762 | let scriptsPersisted = false; | ||
| 763 | try { | ||
| 764 | const updated = await api.projects.updateScripts({ | ||
| 765 | id: activeProject.id, | ||
| 766 | scripts: nextScripts, | ||
| 767 | }); | ||
| 768 | scriptsPersisted = true; | ||
| 769 | |||
| 770 | if (input.keybinding) { | ||
| 771 | const keybindingUpdate = await api.server.upsertKeybinding({ | ||
| 772 | key: input.keybinding, | ||
| 773 | command: commandForProjectScript(nextId), | ||
| 774 | }); | ||
| 775 | queryClient.setQueryData( | ||
| 776 | serverQueryKeys.config(), | ||
| 777 | (current: { cwd: string; keybindings: ResolvedKeybindingsConfig } | undefined) => | ||
| 778 | current | ||
| 779 | ? { ...current, keybindings: keybindingUpdate.keybindings } | ||
| 780 | : { | ||
| 781 | cwd: activeProject.cwd, | ||
| 782 | keybindings: keybindingUpdate.keybindings, | ||
| 783 | }, | ||
| 784 | ); | ||
| 785 | } | ||
| 786 | |||
| 787 | dispatch({ | ||
| 788 | type: "SET_PROJECT_SCRIPTS", | ||
| 789 | projectId: updated.project.id, | ||
| 790 | scripts: updated.project.scripts, | ||
| 791 | }); | ||
| 792 | } catch (error) { | ||
| 793 | if (scriptsPersisted) { | ||
| 794 | await api.projects | ||
| 795 | .updateScripts({ | ||
| 796 | id: activeProject.id, | ||
| 797 | scripts: activeProject.scripts, | ||
| 798 | }) | ||
| 799 | .catch(() => undefined); | ||
| 800 | } | ||
| 801 | dispatch({ | ||
| 802 | type: "SET_PROJECT_SCRIPTS", | ||
| 803 | projectId: activeProject.id, | ||
| 804 | scripts: activeProject.scripts, | ||
| 805 | }); | ||
| 806 | throw error; | ||
| 807 | } | ||
| 808 | }, | ||
| 809 | [activeProject, api, dispatch, queryClient], | ||
| 810 | ); | ||
| 811 | const updateProjectScript = useCallback( | ||
| 812 | async (scriptId: string, input: NewProjectScriptInput) => { | ||
| 813 | if (!activeProject) return; | ||
| 814 | const existingScript = activeProject.scripts.find((script) => script.id === scriptId); | ||
| 815 | if (!existingScript) { | ||
| 816 | throw new Error("Script not found."); | ||
| 817 | } | ||
| 818 | |||
| 819 | const updatedScript: ProjectScript = { | ||
| 820 | ...existingScript, | ||
| 821 | name: input.name, | ||
| 822 | command: input.command, | ||
| 823 | icon: input.icon, | ||
| 824 | runOnWorktreeCreate: input.runOnWorktreeCreate, | ||
| 825 | }; | ||
| 826 | const nextScripts = activeProject.scripts.map((script) => | ||
| 827 | script.id === scriptId | ||
| 828 | ? updatedScript | ||
| 829 | : input.runOnWorktreeCreate | ||
| 830 | ? { ...script, runOnWorktreeCreate: false } | ||
| 831 | : script, | ||
| 832 | ); | ||
| 833 | |||
| 834 | dispatch({ | ||
| 835 | type: "SET_PROJECT_SCRIPTS", | ||
| 836 | projectId: activeProject.id, | ||
| 837 | scripts: nextScripts, | ||
| 838 | }); | ||
| 839 | |||
| 840 | if (!isElectron || !api) return; | ||
| 841 | let scriptsPersisted = false; | ||
| 726 | try { | 842 | try { |
| 727 | const updated = await api.projects.updateScripts({ | 843 | const updated = await api.projects.updateScripts({ |
| 728 | id: activeProject.id, | 844 | id: activeProject.id, |
| 729 | scripts: nextScripts, | 845 | scripts: nextScripts, |
| 730 | }); | 846 | }); |
| 847 | scriptsPersisted = true; | ||
| 848 | |||
| 849 | if (input.keybinding) { | ||
| 850 | const keybindingUpdate = await api.server.upsertKeybinding({ | ||
| 851 | key: input.keybinding, | ||
| 852 | command: commandForProjectScript(scriptId), | ||
| 853 | }); | ||
| 854 | queryClient.setQueryData( | ||
| 855 | serverQueryKeys.config(), | ||
| 856 | (current: { cwd: string; keybindings: ResolvedKeybindingsConfig } | undefined) => | ||
| 857 | current | ||
| 858 | ? { ...current, keybindings: keybindingUpdate.keybindings } | ||
| 859 | : { | ||
| 860 | cwd: activeProject.cwd, | ||
| 861 | keybindings: keybindingUpdate.keybindings, | ||
| 862 | }, | ||
| 863 | ); | ||
| 864 | } | ||
| 865 | |||
| 731 | dispatch({ | 866 | dispatch({ |
| 732 | type: "SET_PROJECT_SCRIPTS", | 867 | type: "SET_PROJECT_SCRIPTS", |
| 733 | projectId: updated.project.id, | 868 | projectId: updated.project.id, |
| 734 | scripts: updated.project.scripts, | 869 | scripts: updated.project.scripts, |
| 735 | }); | 870 | }); |
| 736 | } catch (error) { | 871 | } catch (error) { |
| 872 | if (scriptsPersisted) { | ||
| 873 | await api.projects | ||
| 874 | .updateScripts({ | ||
| 875 | id: activeProject.id, | ||
| 876 | scripts: activeProject.scripts, | ||
| 877 | }) | ||
| 878 | .catch(() => undefined); | ||
| 879 | } | ||
| 737 | dispatch({ | 880 | dispatch({ |
| 738 | type: "SET_PROJECT_SCRIPTS", | 881 | type: "SET_PROJECT_SCRIPTS", |
| 739 | projectId: activeProject.id, | 882 | projectId: activeProject.id, |
| ... | @@ -742,7 +885,7 @@ export default function ChatView() { | ... | @@ -742,7 +885,7 @@ export default function ChatView() { |
| 742 | throw error; | 885 | throw error; |
| 743 | } | 886 | } |
| 744 | }, | 887 | }, |
| 745 | [activeProject, api, dispatch], | 888 | [activeProject, api, dispatch, queryClient], |
| 746 | ); | 889 | ); |
| 747 | 890 | ||
| 748 | const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => { | 891 | const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => { |
| ... | @@ -768,6 +911,21 @@ export default function ChatView() { | ... | @@ -768,6 +911,21 @@ export default function ChatView() { |
| 768 | } | 911 | } |
| 769 | }; | 912 | }; |
| 770 | 913 | ||
| 914 | useEffect(() => { | ||
| 915 | try { | ||
| 916 | if (Object.keys(lastInvokedScriptByProjectId).length === 0) { | ||
| 917 | localStorage.removeItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY); | ||
| 918 | return; | ||
| 919 | } | ||
| 920 | localStorage.setItem( | ||
| 921 | LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, | ||
| 922 | JSON.stringify(lastInvokedScriptByProjectId), | ||
| 923 | ); | ||
| 924 | } catch { | ||
| 925 | // Ignore storage write failures (private mode, quota exceeded, etc.) | ||
| 926 | } | ||
| 927 | }, [lastInvokedScriptByProjectId]); | ||
| 928 | |||
| 771 | // Auto-scroll on new messages | 929 | // Auto-scroll on new messages |
| 772 | const messageCount = activeThread?.messages.length ?? 0; | 930 | const messageCount = activeThread?.messages.length ?? 0; |
| 773 | const workLogCount = workLogEntries.length; | 931 | const workLogCount = workLogEntries.length; |
| ... | @@ -1192,7 +1350,6 @@ export default function ChatView() { | ... | @@ -1192,7 +1350,6 @@ export default function ChatView() { |
| 1192 | if (setupScript) { | 1350 | if (setupScript) { |
| 1193 | void runProjectScript(setupScript, { | 1351 | void runProjectScript(setupScript, { |
| 1194 | cwd: result.worktree.path, | 1352 | cwd: result.worktree.path, |
| 1195 | env: { T3CODE_PROJECT_ROOT: activeProject.cwd }, | ||
| 1196 | }); | 1353 | }); |
| 1197 | } | 1354 | } |
| 1198 | } catch (err) { | 1355 | } catch (err) { |
| ... | @@ -1474,6 +1631,9 @@ export default function ChatView() { | ... | @@ -1474,6 +1631,9 @@ export default function ChatView() { |
| 1474 | activeThreadTitle={activeThread.title} | 1631 | activeThreadTitle={activeThread.title} |
| 1475 | activeProjectName={activeProject?.name} | 1632 | activeProjectName={activeProject?.name} |
| 1476 | activeProjectScripts={activeProject?.scripts} | 1633 | activeProjectScripts={activeProject?.scripts} |
| 1634 | preferredScriptId={ | ||
| 1635 | activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null | ||
| 1636 | } | ||
| 1477 | keybindings={keybindings} | 1637 | keybindings={keybindings} |
| 1478 | api={api} | 1638 | api={api} |
| 1479 | gitCwd={gitCwd} | 1639 | gitCwd={gitCwd} |
| ... | @@ -1482,6 +1642,7 @@ export default function ChatView() { | ... | @@ -1482,6 +1642,7 @@ export default function ChatView() { |
| 1482 | void runProjectScript(script); | 1642 | void runProjectScript(script); |
| 1483 | }} | 1643 | }} |
| 1484 | onAddProjectScript={saveProjectScript} | 1644 | onAddProjectScript={saveProjectScript} |
| 1645 | onUpdateProjectScript={updateProjectScript} | ||
| 1485 | onToggleDiff={onToggleDiff} | 1646 | onToggleDiff={onToggleDiff} |
| 1486 | /> | 1647 | /> |
| 1487 | </header> | 1648 | </header> |
| ... | @@ -1806,12 +1967,14 @@ interface ChatHeaderProps { | ... | @@ -1806,12 +1967,14 @@ interface ChatHeaderProps { |
| 1806 | activeThreadTitle: string; | 1967 | activeThreadTitle: string; |
| 1807 | activeProjectName: string | undefined; | 1968 | activeProjectName: string | undefined; |
| 1808 | activeProjectScripts: ProjectScript[] | undefined; | 1969 | activeProjectScripts: ProjectScript[] | undefined; |
| 1970 | preferredScriptId: string | null; | ||
| 1809 | keybindings: ResolvedKeybindingsConfig; | 1971 | keybindings: ResolvedKeybindingsConfig; |
| 1810 | api: NativeApi | undefined; | 1972 | api: NativeApi | undefined; |
| 1811 | gitCwd: string | null; | 1973 | gitCwd: string | null; |
| 1812 | diffOpen: boolean; | 1974 | diffOpen: boolean; |
| 1813 | onRunProjectScript: (script: ProjectScript) => void; | 1975 | onRunProjectScript: (script: ProjectScript) => void; |
| 1814 | onAddProjectScript: (input: NewProjectScriptInput) => Promise<void>; | 1976 | onAddProjectScript: (input: NewProjectScriptInput) => Promise<void>; |
| 1977 | onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise<void>; | ||
| 1815 | onToggleDiff: () => void; | 1978 | onToggleDiff: () => void; |
| 1816 | } | 1979 | } |
| 1817 | 1980 | ||
| ... | @@ -1819,12 +1982,14 @@ const ChatHeader = memo(function ChatHeader({ | ... | @@ -1819,12 +1982,14 @@ const ChatHeader = memo(function ChatHeader({ |
| 1819 | activeThreadTitle, | 1982 | activeThreadTitle, |
| 1820 | activeProjectName, | 1983 | activeProjectName, |
| 1821 | activeProjectScripts, | 1984 | activeProjectScripts, |
| 1985 | preferredScriptId, | ||
| 1822 | keybindings, | 1986 | keybindings, |
| 1823 | api, | 1987 | api, |
| 1824 | gitCwd, | 1988 | gitCwd, |
| 1825 | diffOpen, | 1989 | diffOpen, |
| 1826 | onRunProjectScript, | 1990 | onRunProjectScript, |
| 1827 | onAddProjectScript, | 1991 | onAddProjectScript, |
| 1992 | onUpdateProjectScript, | ||
| 1828 | onToggleDiff, | 1993 | onToggleDiff, |
| 1829 | }: ChatHeaderProps) { | 1994 | }: ChatHeaderProps) { |
| 1830 | return ( | 1995 | return ( |
| ... | @@ -1840,8 +2005,10 @@ const ChatHeader = memo(function ChatHeader({ | ... | @@ -1840,8 +2005,10 @@ const ChatHeader = memo(function ChatHeader({ |
| 1840 | <ProjectScriptsControl | 2005 | <ProjectScriptsControl |
| 1841 | scripts={activeProjectScripts} | 2006 | scripts={activeProjectScripts} |
| 1842 | keybindings={keybindings} | 2007 | keybindings={keybindings} |
| 2008 | preferredScriptId={preferredScriptId} | ||
| 1843 | onRunScript={onRunProjectScript} | 2009 | onRunScript={onRunProjectScript} |
| 1844 | onAddScript={onAddProjectScript} | 2010 | onAddScript={onAddProjectScript} |
| 2011 | onUpdateScript={onUpdateProjectScript} | ||
| 1845 | /> | 2012 | /> |
| 1846 | )} | 2013 | )} |
| 1847 | {activeProjectName && <OpenInPicker keybindings={keybindings} />} | 2014 | {activeProjectName && <OpenInPicker keybindings={keybindings} />} |
apps/web/src/components/ProjectScriptsControl.tsx+263-72| ... | @@ -4,7 +4,9 @@ import type { | ... | @@ -4,7 +4,9 @@ import type { |
| 4 | ResolvedKeybindingsConfig, | 4 | ResolvedKeybindingsConfig, |
| 5 | } from "@t3tools/contracts"; | 5 | } from "@t3tools/contracts"; |
| 6 | import { | 6 | import { |
| 7 | BugIcon, | ||
| 7 | ChevronDownIcon, | 8 | ChevronDownIcon, |
| 9 | CogIcon, | ||
| 8 | FlaskConicalIcon, | 10 | FlaskConicalIcon, |
| 9 | HammerIcon, | 11 | HammerIcon, |
| 10 | ListChecksIcon, | 12 | ListChecksIcon, |
| ... | @@ -12,14 +14,11 @@ import { | ... | @@ -12,14 +14,11 @@ import { |
| 12 | PlusIcon, | 14 | PlusIcon, |
| 13 | WrenchIcon, | 15 | WrenchIcon, |
| 14 | } from "lucide-react"; | 16 | } from "lucide-react"; |
| 15 | import { type FormEvent, useMemo, useState } from "react"; | 17 | import React, { type FormEvent, type KeyboardEvent, useMemo, useState } from "react"; |
| 16 | 18 | ||
| 17 | import { | 19 | import { commandForProjectScript, primaryProjectScript } from "~/projectScripts"; |
| 18 | commandForProjectScript, | ||
| 19 | nextProjectScriptId, | ||
| 20 | primaryProjectScript, | ||
| 21 | } from "~/projectScripts"; | ||
| 22 | import { shortcutLabelForCommand } from "~/keybindings"; | 20 | import { shortcutLabelForCommand } from "~/keybindings"; |
| 21 | import { isMacPlatform } from "~/lib/utils"; | ||
| 23 | import { Button } from "./ui/button"; | 22 | import { Button } from "./ui/button"; |
| 24 | import { | 23 | import { |
| 25 | Dialog, | 24 | Dialog, |
| ... | @@ -34,7 +33,7 @@ import { Group, GroupSeparator } from "./ui/group"; | ... | @@ -34,7 +33,7 @@ import { Group, GroupSeparator } from "./ui/group"; |
| 34 | import { Input } from "./ui/input"; | 33 | import { Input } from "./ui/input"; |
| 35 | import { Label } from "./ui/label"; | 34 | import { Label } from "./ui/label"; |
| 36 | import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu"; | 35 | import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu"; |
| 37 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; | 36 | import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; |
| 38 | import { Switch } from "./ui/switch"; | 37 | import { Switch } from "./ui/switch"; |
| 39 | import { Textarea } from "./ui/textarea"; | 38 | import { Textarea } from "./ui/textarea"; |
| 40 | 39 | ||
| ... | @@ -44,6 +43,7 @@ const SCRIPT_ICONS: Array<{ id: ProjectScriptIcon; label: string }> = [ | ... | @@ -44,6 +43,7 @@ const SCRIPT_ICONS: Array<{ id: ProjectScriptIcon; label: string }> = [ |
| 44 | { id: "lint", label: "Lint" }, | 43 | { id: "lint", label: "Lint" }, |
| 45 | { id: "configure", label: "Configure" }, | 44 | { id: "configure", label: "Configure" }, |
| 46 | { id: "build", label: "Build" }, | 45 | { id: "build", label: "Build" }, |
| 46 | { id: "debug", label: "Debug" }, | ||
| 47 | ]; | 47 | ]; |
| 48 | 48 | ||
| 49 | function ScriptIcon({ | 49 | function ScriptIcon({ |
| ... | @@ -57,6 +57,7 @@ function ScriptIcon({ | ... | @@ -57,6 +57,7 @@ function ScriptIcon({ |
| 57 | if (icon === "lint") return <ListChecksIcon className={className} />; | 57 | if (icon === "lint") return <ListChecksIcon className={className} />; |
| 58 | if (icon === "configure") return <WrenchIcon className={className} />; | 58 | if (icon === "configure") return <WrenchIcon className={className} />; |
| 59 | if (icon === "build") return <HammerIcon className={className} />; | 59 | if (icon === "build") return <HammerIcon className={className} />; |
| 60 | if (icon === "debug") return <BugIcon className={className} />; | ||
| 60 | return <PlayIcon className={className} />; | 61 | return <PlayIcon className={className} />; |
| 61 | } | 62 | } |
| 62 | 63 | ||
| ... | @@ -65,39 +66,133 @@ export interface NewProjectScriptInput { | ... | @@ -65,39 +66,133 @@ export interface NewProjectScriptInput { |
| 65 | command: string; | 66 | command: string; |
| 66 | icon: ProjectScriptIcon; | 67 | icon: ProjectScriptIcon; |
| 67 | runOnWorktreeCreate: boolean; | 68 | runOnWorktreeCreate: boolean; |
| 69 | keybinding: string | null; | ||
| 68 | } | 70 | } |
| 69 | 71 | ||
| 70 | interface ProjectScriptsControlProps { | 72 | interface ProjectScriptsControlProps { |
| 71 | scripts: ProjectScript[]; | 73 | scripts: ProjectScript[]; |
| 72 | keybindings: ResolvedKeybindingsConfig; | 74 | keybindings: ResolvedKeybindingsConfig; |
| 75 | preferredScriptId?: string | null; | ||
| 73 | onRunScript: (script: ProjectScript) => void; | 76 | onRunScript: (script: ProjectScript) => void; |
| 74 | onAddScript: (input: NewProjectScriptInput) => Promise<void> | void; | 77 | onAddScript: (input: NewProjectScriptInput) => Promise<void> | void; |
| 78 | onUpdateScript: (scriptId: string, input: NewProjectScriptInput) => Promise<void> | void; | ||
| 79 | } | ||
| 80 | |||
| 81 | function normalizeShortcutKeyToken(key: string): string | null { | ||
| 82 | const normalized = key.toLowerCase(); | ||
| 83 | if ( | ||
| 84 | normalized === "meta" || | ||
| 85 | normalized === "control" || | ||
| 86 | normalized === "ctrl" || | ||
| 87 | normalized === "shift" || | ||
| 88 | normalized === "alt" || | ||
| 89 | normalized === "option" | ||
| 90 | ) { | ||
| 91 | return null; | ||
| 92 | } | ||
| 93 | if (normalized === " ") return "space"; | ||
| 94 | if (normalized === "escape") return "esc"; | ||
| 95 | if (normalized === "arrowup") return "arrowup"; | ||
| 96 | if (normalized === "arrowdown") return "arrowdown"; | ||
| 97 | if (normalized === "arrowleft") return "arrowleft"; | ||
| 98 | if (normalized === "arrowright") return "arrowright"; | ||
| 99 | if (normalized.length === 1) return normalized; | ||
| 100 | if (normalized.startsWith("f") && normalized.length <= 3) return normalized; | ||
| 101 | if (normalized === "enter" || normalized === "tab" || normalized === "backspace") { | ||
| 102 | return normalized; | ||
| 103 | } | ||
| 104 | if (normalized === "delete" || normalized === "home" || normalized === "end") { | ||
| 105 | return normalized; | ||
| 106 | } | ||
| 107 | if (normalized === "pageup" || normalized === "pagedown") return normalized; | ||
| 108 | return null; | ||
| 109 | } | ||
| 110 | |||
| 111 | function keybindingFromEvent(event: KeyboardEvent<HTMLInputElement>): string | null { | ||
| 112 | const keyToken = normalizeShortcutKeyToken(event.key); | ||
| 113 | if (!keyToken) return null; | ||
| 114 | |||
| 115 | const parts: string[] = []; | ||
| 116 | if (isMacPlatform(navigator.platform)) { | ||
| 117 | if (event.metaKey) parts.push("mod"); | ||
| 118 | if (event.ctrlKey) parts.push("ctrl"); | ||
| 119 | } else { | ||
| 120 | if (event.ctrlKey) parts.push("mod"); | ||
| 121 | if (event.metaKey) parts.push("meta"); | ||
| 122 | } | ||
| 123 | if (event.altKey) parts.push("alt"); | ||
| 124 | if (event.shiftKey) parts.push("shift"); | ||
| 125 | parts.push(keyToken); | ||
| 126 | return parts.join("+"); | ||
| 127 | } | ||
| 128 | |||
| 129 | function keybindingValueForCommand( | ||
| 130 | keybindings: ResolvedKeybindingsConfig, | ||
| 131 | command: string, | ||
| 132 | ): string | null { | ||
| 133 | for (let index = keybindings.length - 1; index >= 0; index -= 1) { | ||
| 134 | const binding = keybindings[index]; | ||
| 135 | if (!binding || binding.command !== command) continue; | ||
| 136 | |||
| 137 | const parts: string[] = []; | ||
| 138 | if (binding.shortcut.modKey) parts.push("mod"); | ||
| 139 | if (binding.shortcut.ctrlKey) parts.push("ctrl"); | ||
| 140 | if (binding.shortcut.metaKey) parts.push("meta"); | ||
| 141 | if (binding.shortcut.altKey) parts.push("alt"); | ||
| 142 | if (binding.shortcut.shiftKey) parts.push("shift"); | ||
| 143 | const keyToken = | ||
| 144 | binding.shortcut.key === " " | ||
| 145 | ? "space" | ||
| 146 | : binding.shortcut.key === "escape" | ||
| 147 | ? "esc" | ||
| 148 | : binding.shortcut.key; | ||
| 149 | parts.push(keyToken); | ||
| 150 | return parts.join("+"); | ||
| 151 | } | ||
| 152 | return null; | ||
| 75 | } | 153 | } |
| 76 | 154 | ||
| 77 | export default function ProjectScriptsControl({ | 155 | export default function ProjectScriptsControl({ |
| 78 | scripts, | 156 | scripts, |
| 79 | keybindings, | 157 | keybindings, |
| 158 | preferredScriptId = null, | ||
| 80 | onRunScript, | 159 | onRunScript, |
| 81 | onAddScript, | 160 | onAddScript, |
| 161 | onUpdateScript, | ||
| 82 | }: ProjectScriptsControlProps) { | 162 | }: ProjectScriptsControlProps) { |
| 163 | const addScriptFormId = React.useId(); | ||
| 164 | const [editingScriptId, setEditingScriptId] = useState<string | null>(null); | ||
| 83 | const [dialogOpen, setDialogOpen] = useState(false); | 165 | const [dialogOpen, setDialogOpen] = useState(false); |
| 84 | const [name, setName] = useState(""); | 166 | const [name, setName] = useState(""); |
| 85 | const [command, setCommand] = useState(""); | 167 | const [command, setCommand] = useState(""); |
| 86 | const [icon, setIcon] = useState<ProjectScriptIcon>("play"); | 168 | const [icon, setIcon] = useState<ProjectScriptIcon>("play"); |
| 169 | const [iconPickerOpen, setIconPickerOpen] = useState(false); | ||
| 87 | const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false); | 170 | const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false); |
| 171 | const [keybinding, setKeybinding] = useState(""); | ||
| 88 | const [validationError, setValidationError] = useState<string | null>(null); | 172 | const [validationError, setValidationError] = useState<string | null>(null); |
| 89 | 173 | ||
| 90 | const primaryScript = useMemo(() => primaryProjectScript(scripts), [scripts]); | 174 | const primaryScript = useMemo(() => { |
| 91 | const keybindingCommandPreview = useMemo( | 175 | if (preferredScriptId) { |
| 92 | () => | 176 | const preferred = scripts.find((script) => script.id === preferredScriptId); |
| 93 | commandForProjectScript( | 177 | if (preferred) return preferred; |
| 94 | nextProjectScriptId( | 178 | } |
| 95 | name.length > 0 ? name : "script", | 179 | return primaryProjectScript(scripts); |
| 96 | scripts.map((script) => script.id), | 180 | }, [preferredScriptId, scripts]); |
| 97 | ), | 181 | const isEditing = editingScriptId !== null; |
| 98 | ), | 182 | const dropdownItemClassName = |
| 99 | [name, scripts], | 183 | "data-highlighted:bg-transparent data-highlighted:text-foreground hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground data-highlighted:hover:bg-accent data-highlighted:hover:text-accent-foreground data-highlighted:focus-visible:bg-accent data-highlighted:focus-visible:text-accent-foreground"; |
| 100 | ); | 184 | |
| 185 | const captureKeybinding = (event: KeyboardEvent<HTMLInputElement>) => { | ||
| 186 | if (event.key === "Tab") return; | ||
| 187 | event.preventDefault(); | ||
| 188 | if (event.key === "Backspace" || event.key === "Delete") { | ||
| 189 | setKeybinding(""); | ||
| 190 | return; | ||
| 191 | } | ||
| 192 | const next = keybindingFromEvent(event); | ||
| 193 | if (!next) return; | ||
| 194 | setKeybinding(next); | ||
| 195 | }; | ||
| 101 | 196 | ||
| 102 | const submitAddScript = async (event: FormEvent) => { | 197 | const submitAddScript = async (event: FormEvent) => { |
| 103 | event.preventDefault(); | 198 | event.preventDefault(); |
| ... | @@ -114,23 +209,51 @@ export default function ProjectScriptsControl({ | ... | @@ -114,23 +209,51 @@ export default function ProjectScriptsControl({ |
| 114 | 209 | ||
| 115 | setValidationError(null); | 210 | setValidationError(null); |
| 116 | try { | 211 | try { |
| 117 | await onAddScript({ | 212 | const payload = { |
| 118 | name: trimmedName, | 213 | name: trimmedName, |
| 119 | command: trimmedCommand, | 214 | command: trimmedCommand, |
| 120 | icon, | 215 | icon, |
| 121 | runOnWorktreeCreate, | 216 | runOnWorktreeCreate, |
| 122 | }); | 217 | keybinding: keybinding.trim() || null, |
| 218 | } satisfies NewProjectScriptInput; | ||
| 219 | if (editingScriptId) { | ||
| 220 | await onUpdateScript(editingScriptId, payload); | ||
| 221 | } else { | ||
| 222 | await onAddScript(payload); | ||
| 223 | } | ||
| 123 | setDialogOpen(false); | 224 | setDialogOpen(false); |
| 225 | setEditingScriptId(null); | ||
| 124 | setName(""); | 226 | setName(""); |
| 125 | setCommand(""); | 227 | setCommand(""); |
| 126 | setIcon("play"); | 228 | setIcon("play"); |
| 229 | setIconPickerOpen(false); | ||
| 127 | setRunOnWorktreeCreate(false); | 230 | setRunOnWorktreeCreate(false); |
| 231 | setKeybinding(""); | ||
| 128 | } catch (error) { | 232 | } catch (error) { |
| 129 | setValidationError(error instanceof Error ? error.message : "Failed to save action."); | 233 | setValidationError(error instanceof Error ? error.message : "Failed to save action."); |
| 130 | } | 234 | } |
| 131 | }; | 235 | }; |
| 132 | 236 | ||
| 133 | const openDialog = () => { | 237 | const openAddDialog = () => { |
| 238 | setEditingScriptId(null); | ||
| 239 | setName(""); | ||
| 240 | setCommand(""); | ||
| 241 | setIcon("play"); | ||
| 242 | setIconPickerOpen(false); | ||
| 243 | setRunOnWorktreeCreate(false); | ||
| 244 | setKeybinding(""); | ||
| 245 | setValidationError(null); | ||
| 246 | setDialogOpen(true); | ||
| 247 | }; | ||
| 248 | |||
| 249 | const openEditDialog = (script: ProjectScript) => { | ||
| 250 | setEditingScriptId(script.id); | ||
| 251 | setName(script.name); | ||
| 252 | setCommand(script.command); | ||
| 253 | setIcon(script.icon); | ||
| 254 | setIconPickerOpen(false); | ||
| 255 | setRunOnWorktreeCreate(script.runOnWorktreeCreate); | ||
| 256 | setKeybinding(keybindingValueForCommand(keybindings, commandForProjectScript(script.id)) ?? ""); | ||
| 134 | setValidationError(null); | 257 | setValidationError(null); |
| 135 | setDialogOpen(true); | 258 | setDialogOpen(true); |
| 136 | }; | 259 | }; |
| ... | @@ -146,10 +269,10 @@ export default function ProjectScriptsControl({ | ... | @@ -146,10 +269,10 @@ export default function ProjectScriptsControl({ |
| 146 | title={`Run ${primaryScript.name}`} | 269 | title={`Run ${primaryScript.name}`} |
| 147 | > | 270 | > |
| 148 | <ScriptIcon icon={primaryScript.icon} /> | 271 | <ScriptIcon icon={primaryScript.icon} /> |
| 149 | <span>{primaryScript.name}</span> | 272 | <span className="ml-0.5">{primaryScript.name}</span> |
| 150 | </Button> | 273 | </Button> |
| 151 | <GroupSeparator /> | 274 | <GroupSeparator /> |
| 152 | <Menu> | 275 | <Menu highlightItemOnHover={false}> |
| 153 | <MenuTrigger | 276 | <MenuTrigger |
| 154 | render={<Button size="icon-xs" variant="outline" aria-label="Script actions" />} | 277 | render={<Button size="icon-xs" variant="outline" aria-label="Script actions" />} |
| 155 | > | 278 | > |
| ... | @@ -162,14 +285,44 @@ export default function ProjectScriptsControl({ | ... | @@ -162,14 +285,44 @@ export default function ProjectScriptsControl({ |
| 162 | commandForProjectScript(script.id), | 285 | commandForProjectScript(script.id), |
| 163 | ); | 286 | ); |
| 164 | return ( | 287 | return ( |
| 165 | <MenuItem key={script.id} onClick={() => onRunScript(script)}> | 288 | <MenuItem |
| 289 | key={script.id} | ||
| 290 | className={`group ${dropdownItemClassName}`} | ||
| 291 | onClick={() => onRunScript(script)} | ||
| 292 | > | ||
| 166 | <ScriptIcon icon={script.icon} className="size-4" /> | 293 | <ScriptIcon icon={script.icon} className="size-4" /> |
| 167 | {script.runOnWorktreeCreate ? `${script.name} (Setup)` : script.name} | 294 | <span className="truncate"> |
| 168 | {shortcutLabel && <MenuShortcut>{shortcutLabel}</MenuShortcut>} | 295 | {script.runOnWorktreeCreate ? `${script.name}` : script.name} |
| 296 | </span> | ||
| 297 | <span className="relative ms-auto flex h-6 min-w-6 items-center justify-end"> | ||
| 298 | {shortcutLabel && ( | ||
| 299 | <MenuShortcut className="ms-0 transition-opacity group-hover:opacity-0 group-focus-visible:opacity-0"> | ||
| 300 | {shortcutLabel} | ||
| 301 | </MenuShortcut> | ||
| 302 | )} | ||
| 303 | <Button | ||
| 304 | type="button" | ||
| 305 | variant="ghost" | ||
| 306 | size="icon-xs" | ||
| 307 | className="absolute right-0 top-1/2 size-6 -translate-y-1/2 opacity-0 pointer-events-none transition-opacity group-hover:opacity-100 group-hover:pointer-events-auto group-focus-visible:opacity-100 group-focus-visible:pointer-events-auto" | ||
| 308 | aria-label={`Edit ${script.name}`} | ||
| 309 | onPointerDown={(event) => { | ||
| 310 | event.preventDefault(); | ||
| 311 | event.stopPropagation(); | ||
| 312 | }} | ||
| 313 | onClick={(event) => { | ||
| 314 | event.preventDefault(); | ||
| 315 | event.stopPropagation(); | ||
| 316 | openEditDialog(script); | ||
| 317 | }} | ||
| 318 | > | ||
| 319 | <CogIcon className="size-3.5" /> | ||
| 320 | </Button> | ||
| 321 | </span> | ||
| 169 | </MenuItem> | 322 | </MenuItem> |
| 170 | ); | 323 | ); |
| 171 | })} | 324 | })} |
| 172 | <MenuItem onClick={openDialog}> | 325 | <MenuItem className={dropdownItemClassName} onClick={openAddDialog}> |
| 173 | <PlusIcon className="size-4" /> | 326 | <PlusIcon className="size-4" /> |
| 174 | Add action | 327 | Add action |
| 175 | </MenuItem> | 328 | </MenuItem> |
| ... | @@ -177,7 +330,7 @@ export default function ProjectScriptsControl({ | ... | @@ -177,7 +330,7 @@ export default function ProjectScriptsControl({ |
| 177 | </Menu> | 330 | </Menu> |
| 178 | </Group> | 331 | </Group> |
| 179 | ) : ( | 332 | ) : ( |
| 180 | <Button size="xs" variant="outline" onClick={openDialog}> | 333 | <Button size="xs" variant="outline" onClick={openAddDialog}> |
| 181 | <PlusIcon className="size-3.5" /> | 334 | <PlusIcon className="size-3.5" /> |
| 182 | Add action | 335 | Add action |
| 183 | </Button> | 336 | </Button> |
| ... | @@ -185,24 +338,75 @@ export default function ProjectScriptsControl({ | ... | @@ -185,24 +338,75 @@ export default function ProjectScriptsControl({ |
| 185 | 338 | ||
| 186 | <Dialog onOpenChange={setDialogOpen} open={dialogOpen}> | 339 | <Dialog onOpenChange={setDialogOpen} open={dialogOpen}> |
| 187 | <DialogPopup> | 340 | <DialogPopup> |
| 188 | <form onSubmit={submitAddScript}> | 341 | <DialogHeader> |
| 189 | <DialogHeader> | 342 | <DialogTitle>{isEditing ? "Edit Action" : "Add Action"}</DialogTitle> |
| 190 | <DialogTitle>Add Action</DialogTitle> | 343 | <DialogDescription> |
| 191 | <DialogDescription> | 344 | Actions are project-scoped commands you can run from the top bar or keybindings. |
| 192 | Actions are project-scoped commands you can run from the top bar or keybindings. | 345 | </DialogDescription> |
| 193 | </DialogDescription> | 346 | </DialogHeader> |
| 194 | </DialogHeader> | 347 | <DialogPanel> |
| 195 | <DialogPanel className="space-y-4"> | 348 | <form id={addScriptFormId} className="space-y-4" onSubmit={submitAddScript}> |
| 196 | <div className="space-y-1.5"> | 349 | <div className="space-y-1.5"> |
| 197 | <Label htmlFor="script-name">Name</Label> | 350 | <Label htmlFor="script-name">Name</Label> |
| 351 | <div className="flex items-center gap-2"> | ||
| 352 | <Popover onOpenChange={setIconPickerOpen} open={iconPickerOpen}> | ||
| 353 | <PopoverTrigger | ||
| 354 | render={ | ||
| 355 | <Button | ||
| 356 | type="button" | ||
| 357 | variant="outline" | ||
| 358 | className="size-9 shrink-0 hover:bg-popover active:bg-popover data-pressed:bg-popover data-pressed:shadow-xs/5 data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)]" | ||
| 359 | aria-label="Choose icon" | ||
| 360 | /> | ||
| 361 | } | ||
| 362 | > | ||
| 363 | <ScriptIcon icon={icon} className="size-4.5" /> | ||
| 364 | </PopoverTrigger> | ||
| 365 | <PopoverPopup align="start"> | ||
| 366 | <div className="grid grid-cols-3 gap-2"> | ||
| 367 | {SCRIPT_ICONS.map((entry) => { | ||
| 368 | const isSelected = entry.id === icon; | ||
| 369 | return ( | ||
| 370 | <button | ||
| 371 | key={entry.id} | ||
| 372 | type="button" | ||
| 373 | className={`relative flex flex-col items-center gap-2 rounded-md border px-2 py-2 text-xs ${ | ||
| 374 | isSelected | ||
| 375 | ? "border-primary/70 bg-primary/10" | ||
| 376 | : "border-border/70 hover:bg-accent/60" | ||
| 377 | }`} | ||
| 378 | onClick={() => { | ||
| 379 | setIcon(entry.id); | ||
| 380 | setIconPickerOpen(false); | ||
| 381 | }} | ||
| 382 | > | ||
| 383 | <ScriptIcon icon={entry.id} className="size-4" /> | ||
| 384 | <span>{entry.label}</span> | ||
| 385 | </button> | ||
| 386 | ); | ||
| 387 | })} | ||
| 388 | </div> | ||
| 389 | </PopoverPopup> | ||
| 390 | </Popover> | ||
| 391 | <Input | ||
| 392 | id="script-name" | ||
| 393 | placeholder="Test" | ||
| 394 | value={name} | ||
| 395 | onChange={(event) => setName(event.target.value)} | ||
| 396 | /> | ||
| 397 | </div> | ||
| 398 | </div> | ||
| 399 | <div className="space-y-1.5"> | ||
| 400 | <Label htmlFor="script-keybinding">Keybinding</Label> | ||
| 198 | <Input | 401 | <Input |
| 199 | id="script-name" | 402 | id="script-keybinding" |
| 200 | placeholder="Test" | 403 | placeholder="Press shortcut" |
| 201 | value={name} | 404 | value={keybinding} |
| 202 | onChange={(event) => setName(event.target.value)} | 405 | readOnly |
| 406 | onKeyDown={captureKeybinding} | ||
| 203 | /> | 407 | /> |
| 204 | <p className="text-xs text-muted-foreground"> | 408 | <p className="text-xs text-muted-foreground"> |
| 205 | Keybinding command: <code>{keybindingCommandPreview}</code> | 409 | Press a shortcut. Use <code>Backspace</code> to clear. |
| 206 | </p> | 410 | </p> |
| 207 | </div> | 411 | </div> |
| 208 | <div className="space-y-1.5"> | 412 | <div className="space-y-1.5"> |
| ... | @@ -214,44 +418,31 @@ export default function ProjectScriptsControl({ | ... | @@ -214,44 +418,31 @@ export default function ProjectScriptsControl({ |
| 214 | onChange={(event) => setCommand(event.target.value)} | 418 | onChange={(event) => setCommand(event.target.value)} |
| 215 | /> | 419 | /> |
| 216 | </div> | 420 | </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"> | 421 | <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> | 422 | <span>Run automatically on worktree creation</span> |
| 241 | <Switch | 423 | <Switch |
| 242 | checked={runOnWorktreeCreate} | 424 | checked={runOnWorktreeCreate} |
| 243 | onCheckedChange={(checked) => setRunOnWorktreeCreate(Boolean(checked))} | 425 | onCheckedChange={(checked) => setRunOnWorktreeCreate(Boolean(checked))} |
| 244 | /> | 426 | /> |
| 245 | </label> | 427 | </label> |
| 246 | {validationError && <p className="text-sm text-destructive">{validationError}</p>} | 428 | {validationError && <p className="text-sm text-destructive">{validationError}</p>} |
| 247 | </DialogPanel> | 429 | </form> |
| 248 | <DialogFooter> | 430 | </DialogPanel> |
| 249 | <Button type="button" variant="outline" onClick={() => setDialogOpen(false)}> | 431 | <DialogFooter> |
| 250 | Cancel | 432 | <Button |
| 251 | </Button> | 433 | type="button" |
| 252 | <Button type="submit">Save action</Button> | 434 | variant="outline" |
| 253 | </DialogFooter> | 435 | onClick={() => { |
| 254 | </form> | 436 | setDialogOpen(false); |
| 437 | setEditingScriptId(null); | ||
| 438 | }} | ||
| 439 | > | ||
| 440 | Cancel | ||
| 441 | </Button> | ||
| 442 | <Button form={addScriptFormId} type="submit"> | ||
| 443 | {isEditing ? "Save changes" : "Save action"} | ||
| 444 | </Button> | ||
| 445 | </DialogFooter> | ||
| 255 | </DialogPopup> | 446 | </DialogPopup> |
| 256 | </Dialog> | 447 | </Dialog> |
| 257 | </> | 448 | </> |
apps/web/src/projectScripts.test.ts+49| ... | @@ -5,6 +5,7 @@ import { | ... | @@ -5,6 +5,7 @@ import { |
| 5 | injectEnvIntoShellCommand, | 5 | injectEnvIntoShellCommand, |
| 6 | nextProjectScriptId, | 6 | nextProjectScriptId, |
| 7 | primaryProjectScript, | 7 | primaryProjectScript, |
| 8 | projectScriptRuntimeEnv, | ||
| 8 | projectScriptIdFromCommand, | 9 | projectScriptIdFromCommand, |
| 9 | setupProjectScript, | 10 | setupProjectScript, |
| 10 | } from "./projectScripts"; | 11 | } from "./projectScripts"; |
| ... | @@ -54,4 +55,52 @@ describe("projectScripts helpers", () => { | ... | @@ -54,4 +55,52 @@ describe("projectScripts helpers", () => { |
| 54 | expect(primaryProjectScript(scripts)?.id).toBe("test"); | 55 | expect(primaryProjectScript(scripts)?.id).toBe("test"); |
| 55 | expect(setupProjectScript(scripts)?.id).toBe("setup"); | 56 | expect(setupProjectScript(scripts)?.id).toBe("setup"); |
| 56 | }); | 57 | }); |
| 58 | |||
| 59 | it("builds default runtime env for scripts", () => { | ||
| 60 | const env = projectScriptRuntimeEnv({ | ||
| 61 | project: { id: "project-1", name: "acme-web", cwd: "/repo" }, | ||
| 62 | script: { | ||
| 63 | id: "test", | ||
| 64 | name: "Test", | ||
| 65 | icon: "test", | ||
| 66 | runOnWorktreeCreate: false, | ||
| 67 | }, | ||
| 68 | threadId: "thread-1", | ||
| 69 | worktreePath: "/repo/worktree-a", | ||
| 70 | }); | ||
| 71 | |||
| 72 | expect(env).toMatchObject({ | ||
| 73 | T3CODE_PROJECT_ROOT: "/repo", | ||
| 74 | T3CODE_PROJECT_ID: "project-1", | ||
| 75 | T3CODE_PROJECT_NAME: "acme-web", | ||
| 76 | T3CODE_THREAD_ID: "thread-1", | ||
| 77 | T3CODE_SCRIPT_ID: "test", | ||
| 78 | T3CODE_SCRIPT_NAME: "Test", | ||
| 79 | T3CODE_SCRIPT_ICON: "test", | ||
| 80 | T3CODE_SCRIPT_IS_SETUP: "0", | ||
| 81 | T3CODE_WORKTREE_PATH: "/repo/worktree-a", | ||
| 82 | }); | ||
| 83 | }); | ||
| 84 | |||
| 85 | it("allows overriding runtime env values", () => { | ||
| 86 | const env = projectScriptRuntimeEnv({ | ||
| 87 | project: { id: "project-1", name: "acme-web", cwd: "/repo" }, | ||
| 88 | script: { | ||
| 89 | id: "setup", | ||
| 90 | name: "Setup", | ||
| 91 | icon: "configure", | ||
| 92 | runOnWorktreeCreate: true, | ||
| 93 | }, | ||
| 94 | threadId: "thread-1", | ||
| 95 | extraEnv: { | ||
| 96 | T3CODE_PROJECT_ROOT: "/custom-root", | ||
| 97 | CUSTOM_FLAG: "1", | ||
| 98 | }, | ||
| 99 | }); | ||
| 100 | |||
| 101 | expect(env.T3CODE_PROJECT_ROOT).toBe("/custom-root"); | ||
| 102 | expect(env.T3CODE_SCRIPT_IS_SETUP).toBe("1"); | ||
| 103 | expect(env.CUSTOM_FLAG).toBe("1"); | ||
| 104 | expect(env.T3CODE_WORKTREE_PATH).toBeUndefined(); | ||
| 105 | }); | ||
| 57 | }); | 106 | }); |
apps/web/src/projectScripts.ts+32| ... | @@ -82,6 +82,38 @@ export function injectEnvIntoShellCommand( | ... | @@ -82,6 +82,38 @@ export function injectEnvIntoShellCommand( |
| 82 | return `env ${assignments} ${command}`; | 82 | return `env ${assignments} ${command}`; |
| 83 | } | 83 | } |
| 84 | 84 | ||
| 85 | interface ProjectScriptRuntimeEnvInput { | ||
| 86 | project: { | ||
| 87 | id: string; | ||
| 88 | name: string; | ||
| 89 | cwd: string; | ||
| 90 | }; | ||
| 91 | script: Pick<ProjectScript, "id" | "name" | "icon" | "runOnWorktreeCreate">; | ||
| 92 | threadId: string; | ||
| 93 | worktreePath?: string | null; | ||
| 94 | extraEnv?: Record<string, string>; | ||
| 95 | } | ||
| 96 | |||
| 97 | export function projectScriptRuntimeEnv(input: ProjectScriptRuntimeEnvInput): Record<string, string> { | ||
| 98 | const env: Record<string, string> = { | ||
| 99 | T3CODE_PROJECT_ROOT: input.project.cwd, | ||
| 100 | T3CODE_PROJECT_ID: input.project.id, | ||
| 101 | T3CODE_PROJECT_NAME: input.project.name, | ||
| 102 | T3CODE_THREAD_ID: input.threadId, | ||
| 103 | T3CODE_SCRIPT_ID: input.script.id, | ||
| 104 | T3CODE_SCRIPT_NAME: input.script.name, | ||
| 105 | T3CODE_SCRIPT_ICON: input.script.icon, | ||
| 106 | T3CODE_SCRIPT_IS_SETUP: input.script.runOnWorktreeCreate ? "1" : "0", | ||
| 107 | }; | ||
| 108 | if (input.worktreePath) { | ||
| 109 | env.T3CODE_WORKTREE_PATH = input.worktreePath; | ||
| 110 | } | ||
| 111 | if (input.extraEnv) { | ||
| 112 | return { ...env, ...input.extraEnv }; | ||
| 113 | } | ||
| 114 | return env; | ||
| 115 | } | ||
| 116 | |||
| 85 | export function primaryProjectScript(scripts: ProjectScript[]): ProjectScript | null { | 117 | export function primaryProjectScript(scripts: ProjectScript[]): ProjectScript | null { |
| 86 | const regular = scripts.find((script) => !script.runOnWorktreeCreate); | 118 | const regular = scripts.find((script) => !script.runOnWorktreeCreate); |
| 87 | return regular ?? scripts[0] ?? null; | 119 | return regular ?? scripts[0] ?? null; |
apps/web/src/wsNativeApi.ts+1| ... | @@ -143,6 +143,7 @@ export function createWsNativeApi(): NativeApi { | ... | @@ -143,6 +143,7 @@ export function createWsNativeApi(): NativeApi { |
| 143 | }, | 143 | }, |
| 144 | server: { | 144 | server: { |
| 145 | getConfig: () => transport.request(WS_METHODS.serverGetConfig), | 145 | getConfig: () => transport.request(WS_METHODS.serverGetConfig), |
| 146 | upsertKeybinding: (input) => transport.request(WS_METHODS.serverUpsertKeybinding, input), | ||
| 146 | }, | 147 | }, |
| 147 | }; | 148 | }; |
| 148 | 149 |
packages/contracts/src/ipc.ts+2| ... | @@ -46,6 +46,7 @@ import type { | ... | @@ -46,6 +46,7 @@ import type { |
| 46 | TerminalWriteInput, | 46 | TerminalWriteInput, |
| 47 | } from "./terminal"; | 47 | } from "./terminal"; |
| 48 | import type { NewTodoInput, Todo } from "./todo"; | 48 | import type { NewTodoInput, Todo } from "./todo"; |
| 49 | import type { ServerUpsertKeybindingInput, ServerUpsertKeybindingResult } from "./server"; | ||
| 49 | 50 | ||
| 50 | export const EDITORS = [ | 51 | export const EDITORS = [ |
| 51 | { id: "cursor", label: "Cursor", command: "cursor" }, | 52 | { id: "cursor", label: "Cursor", command: "cursor" }, |
| ... | @@ -122,5 +123,6 @@ export interface NativeApi { | ... | @@ -122,5 +123,6 @@ export interface NativeApi { |
| 122 | }; | 123 | }; |
| 123 | server: { | 124 | server: { |
| 124 | getConfig: () => Promise<ServerConfig>; | 125 | getConfig: () => Promise<ServerConfig>; |
| 126 | upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise<ServerUpsertKeybindingResult>; | ||
| 125 | }; | 127 | }; |
| 126 | } | 128 | } |
packages/contracts/src/project.test.ts+17| ... | @@ -105,6 +105,23 @@ describe("project contracts", () => { | ... | @@ -105,6 +105,23 @@ describe("project contracts", () => { |
| 105 | expect(parsed.scripts[0]?.id).toBe("test"); | 105 | expect(parsed.scripts[0]?.id).toBe("test"); |
| 106 | }); | 106 | }); |
| 107 | 107 | ||
| 108 | it("parses debug icon in scripts", () => { | ||
| 109 | const parsed = projectUpdateScriptsInputSchema.parse({ | ||
| 110 | id: "project-1", | ||
| 111 | scripts: [ | ||
| 112 | { | ||
| 113 | id: "debug", | ||
| 114 | name: "Debug", | ||
| 115 | command: "bun --inspect test", | ||
| 116 | icon: "debug", | ||
| 117 | runOnWorktreeCreate: false, | ||
| 118 | }, | ||
| 119 | ], | ||
| 120 | }); | ||
| 121 | |||
| 122 | expect(parsed.scripts[0]?.icon).toBe("debug"); | ||
| 123 | }); | ||
| 124 | |||
| 108 | it("parses update scripts result", () => { | 125 | it("parses update scripts result", () => { |
| 109 | const parsed = projectUpdateScriptsResultSchema.parse({ | 126 | const parsed = projectUpdateScriptsResultSchema.parse({ |
| 110 | project: { | 127 | project: { |
packages/contracts/src/project.ts+8-1| ... | @@ -1,6 +1,13 @@ | ... | @@ -1,6 +1,13 @@ |
| 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; | 3 | export const PROJECT_SCRIPT_ICON_VALUES = [ |
| 4 | "play", | ||
| 5 | "test", | ||
| 6 | "lint", | ||
| 7 | "configure", | ||
| 8 | "build", | ||
| 9 | "debug", | ||
| 10 | ] as const; | ||
| 4 | 11 | ||
| 5 | export const projectScriptIconSchema = z.enum(PROJECT_SCRIPT_ICON_VALUES); | 12 | export const projectScriptIconSchema = z.enum(PROJECT_SCRIPT_ICON_VALUES); |
| 6 | 13 |
packages/contracts/src/server.ts+9-1| ... | @@ -1,9 +1,17 @@ | ... | @@ -1,9 +1,17 @@ |
| 1 | import { z } from "zod"; | 1 | import { z } from "zod"; |
| 2 | import { resolvedKeybindingsConfigSchema } from "./keybindings"; | 2 | import { keybindingRuleSchema, resolvedKeybindingsConfigSchema } from "./keybindings"; |
| 3 | 3 | ||
| 4 | export const serverConfigSchema = z.object({ | 4 | export const serverConfigSchema = z.object({ |
| 5 | cwd: z.string().min(1), | 5 | cwd: z.string().min(1), |
| 6 | keybindings: resolvedKeybindingsConfigSchema.default([]), | 6 | keybindings: resolvedKeybindingsConfigSchema.default([]), |
| 7 | }); | 7 | }); |
| 8 | 8 | ||
| 9 | export const serverUpsertKeybindingInputSchema = keybindingRuleSchema; | ||
| 10 | |||
| 11 | export const serverUpsertKeybindingResultSchema = z.object({ | ||
| 12 | keybindings: resolvedKeybindingsConfigSchema.default([]), | ||
| 13 | }); | ||
| 14 | |||
| 9 | export type ServerConfig = z.infer<typeof serverConfigSchema>; | 15 | export type ServerConfig = z.infer<typeof serverConfigSchema>; |
| 16 | export type ServerUpsertKeybindingInput = z.infer<typeof serverUpsertKeybindingInputSchema>; | ||
| 17 | export type ServerUpsertKeybindingResult = z.infer<typeof serverUpsertKeybindingResultSchema>; |
packages/contracts/src/ws.ts+1| ... | @@ -42,6 +42,7 @@ export const WS_METHODS = { | ... | @@ -42,6 +42,7 @@ export const WS_METHODS = { |
| 42 | 42 | ||
| 43 | // Server meta | 43 | // Server meta |
| 44 | serverGetConfig: "server.getConfig", | 44 | serverGetConfig: "server.getConfig", |
| 45 | serverUpsertKeybinding: "server.upsertKeybinding", | ||
| 45 | } as const; | 46 | } as const; |
| 46 | 47 | ||
| 47 | // ── Push Event Channels ────────────────────────────────────────────── | 48 | // ── Push Event Channels ────────────────────────────────────────────── |