diff --git a/.changeset/terminal-mode-custom-commands.md b/.changeset/terminal-mode-custom-commands.md new file mode 100644 index 0000000..96a4cef --- /dev/null +++ b/.changeset/terminal-mode-custom-commands.md @@ -0,0 +1,5 @@ +--- +"git-work-grove": minor +--- + +Add terminal execution mode for custom commands diff --git a/README.md b/README.md index 9c9342e..380b079 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,36 @@ Open VS Code Settings (`Cmd+,` / `Ctrl+,`) and search for `git-work-grove`: ### Custom Commands -Define custom commands that appear in the tree view context menu. Two settings are available — one for directory items (repository/worktree), one for workspace file items: +Define custom commands that appear in the tree view context menu. Two settings are available: + +- `git-work-grove.customCommands.directory` — for repository/worktree items +- `git-work-grove.customCommands.workspace` — for workspace file items + +#### Entry Schema + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| `label` | `string` | Yes | Display text in context menu and QuickPick | +| `command` | `string[]` | Yes | Command as `[bin, ...args]` — supports template variables | +| `env` | `Record` | No | Environment variables — values support template variables | +| `mode` | `"spawn"` \| `"terminal"` | No | Execution mode (default: `"spawn"`) | + +**Execution modes:** + +- **`"spawn"`** (default) — Runs the command as a detached background process (fire-and-forget). The process outlives the extension and runs silently. +- **`"terminal"`** — Runs the command in a VS Code integrated terminal. The terminal stays open so you can see output and interact with the process. Ideal for dev servers, REPLs, and interactive CLI tools. + +#### Template Variables + +Directory items (`customCommands.directory`): `{name}`, `{branch}`, `{ref}`, `{head}`, `{path}` + +Workspace items (`customCommands.workspace`): all of the above, plus `{dir}` (parent directory) and `{worktree}` (parent worktree folder name) + +Both `command` and `env` values support template variables with fallback syntax (`{branch|detached}`) and conditional sections (`{?branch}...{/branch}`). See [Template Customization](https://github.com/vp-tw/vscode-extension-git-work-grove/blob/main/docs/templates.md) for syntax reference. + +#### Examples + +**Run a dev server in terminal:** ```json { @@ -130,19 +159,111 @@ Define custom commands that appear in the tree view context menu. Two settings a { "command": ["npm", "run", "dev"], "env": { "NODE_ENV": "development" }, - "label": "Run Dev Server" + "label": "Run Dev Server", + "mode": "terminal" } - ], + ] +} +``` + +**Open in an external terminal emulator (macOS):** + +```json +{ + "git-work-grove.customCommands.directory": [ + { + "command": ["open", "-a", "Terminal", "{path}"], + "label": "Open in Terminal.app" + } + ] +} +``` + +Other terminal emulators: + +| App | `command` | +|-----|-----------| +| iTerm2 | `["open", "-a", "iTerm", "{path}"]` | +| Ghostty | `["open", "-a", "Ghostty", "--args", "--working-directory={path}", "--window-inherit-working-directory=false"]` | +| WezTerm | `["wezterm", "start", "--cwd", "{path}"]` | +| Alacritty | `["alacritty", "--working-directory", "{path}"]` | +| Kitty | `["kitty", "--directory", "{path}"]` | + +**Open in an external editor:** + +```json +{ + "git-work-grove.customCommands.directory": [ + { + "command": ["zed", "{path}"], + "label": "Open in Zed" + } + ] +} +``` + +**Environment variables with template variables:** + +```json +{ + "git-work-grove.customCommands.directory": [ + { + "command": ["open", "-a", "Ghostty", "--args", "--working-directory={path}"], + "env": { "GHOSTTY_TITLE": "{ref}" }, + "label": "Open in Ghostty" + } + ] +} +``` + +**Multiple commands (shown as QuickPick):** + +```json +{ + "git-work-grove.customCommands.directory": [ + { + "command": ["npm", "run", "dev"], + "label": "Run Dev Server", + "mode": "terminal" + }, + { + "command": ["zed", "{path}"], + "label": "Open in Zed" + } + ] +} +``` + +**Start Claude Code in a worktree:** + +```json +{ + "git-work-grove.customCommands.directory": [ + { + "command": ["claude"], + "label": "Claude Code", + "mode": "terminal" + } + ] +} +``` + +**Workspace file commands** — use `{dir}` for the parent directory or `{path}` for the file itself: + +```json +{ "git-work-grove.customCommands.workspace": [ { - "command": ["code", "--goto", "{path}"], - "label": "Open in Terminal" + "command": ["open", "-a", "Terminal", "{dir}"], + "label": "Open dir in Terminal.app" } ] } ``` -Each entry has a `label` (shown in QuickPick), a `command` array (`[bin, ...args]`), and an optional `env` object. Both `command` and `env` values support template variables (`{name}`, `{branch}`, `{ref}`, `{head}`, `{path}` — workspace items also have `{dir}` and `{worktree}`). +#### Known Limitation + +Terminal mode (`"mode": "terminal"`) uses the `shell-quote` library for POSIX-style shell quoting. This may not work correctly in PowerShell terminals (Windows, macOS, or Linux) for commands with arguments containing spaces or special characters. If your VS Code terminal profile uses PowerShell, prefer `"mode": "spawn"` for such commands. ### Template Customization diff --git a/docs/spec/custom-commands.md b/docs/spec/custom-commands.md index af85608..f84be77 100644 --- a/docs/spec/custom-commands.md +++ b/docs/spec/custom-commands.md @@ -20,6 +20,7 @@ Each entry in the array: | `label` | `string` | Yes | Display text in context menu and QuickPick | | `command` | `string[]` | Yes | Command as `[bin, ...args]` — supports template variables | | `env` | `Record` | No | Environment variables — values support template variables | +| `mode` | `"spawn" \| "terminal"` | No | Execution mode — `"spawn"` (default): detached background process. `"terminal"`: VS Code integrated terminal | ## Template Variables @@ -81,7 +82,7 @@ The menu items are only visible when the corresponding setting array is non-empt 3. A QuickPick appears listing all configured commands for that item type 4. Each QuickPick item shows the `label` and the rendered command as detail 5. User selects a command -6. The command is spawned detached in the background +6. The command is executed based on the entry's `mode`: spawned detached (`"spawn"`) or run in an integrated terminal (`"terminal"`) ## Execution @@ -93,6 +94,20 @@ The menu items are only visible when the corresponding setting array is non-empt - **Command not found**: On macOS, VS Code launched from Finder has a limited PATH. Use full binary paths or `open -a` for GUI apps. - **Logging**: Spawned command is logged to the output channel +## Terminal Mode + +When `mode` is `"terminal"`, the command runs in a VS Code integrated terminal instead of a detached background process. + +- **Terminal name**: Uses the entry's `label` +- **Command**: `sendText(quote([bin, ...args]))` — shell-quoted via the `shell-quote` library +- **Environment**: Only custom `env` is passed (VS Code merges with its inherited environment automatically) +- **Lifecycle**: The terminal remains open after the command finishes — the user can interact or close it manually +- **Error handling**: try/catch around `createTerminal` + `sendText` — shows error via `showErrorMessage` and logs via `logError` + +### Known Limitation + +`shell-quote` produces POSIX-style quoting which may not work correctly in PowerShell terminals (on any platform). Users whose VS Code terminal profile uses PowerShell should prefer `"mode": "spawn"` for commands with arguments containing spaces or special characters. + ## CWD Resolution Same logic as Open in Terminal (see [open-in-terminal.md](open-in-terminal.md)): @@ -189,6 +204,34 @@ Common terminal emulators: } ``` +### Run an interactive CLI tool in terminal + +```json +{ + "git-work-grove.customCommands.directory": [ + { + "command": ["npm", "run", "dev"], + "label": "Run Dev Server", + "mode": "terminal" + } + ] +} +``` + +### Start Claude Code in a worktree + +```json +{ + "git-work-grove.customCommands.directory": [ + { + "command": ["claude"], + "label": "Claude Code", + "mode": "terminal" + } + ] +} +``` + ### Multiple commands ```json diff --git a/package.json b/package.json index 8b00146..d6f61e3 100644 --- a/package.json +++ b/package.json @@ -451,6 +451,12 @@ "type": "string" }, "description": "Environment variables. Values support template variables." + }, + "mode": { + "type": "string", + "enum": ["spawn", "terminal"], + "default": "spawn", + "description": "Execution mode. \"spawn\" runs detached in background (default). \"terminal\" runs in VS Code integrated terminal." } } }, @@ -484,6 +490,12 @@ "type": "string" }, "description": "Environment variables. Values support template variables." + }, + "mode": { + "type": "string", + "enum": ["spawn", "terminal"], + "default": "spawn", + "description": "Execution mode. \"spawn\" runs detached in background (default). \"terminal\" runs in VS Code integrated terminal." } } }, @@ -523,6 +535,7 @@ "devDependencies": { "@antfu/eslint-config": "^7.4.3", "@changesets/cli": "^2.29.8", + "@types/shell-quote": "^1.7.5", "@types/vscode": "^1.95.0", "@vp-tw/eslint-config": "latest", "@vp-tw/tsconfig": "latest", @@ -534,5 +547,8 @@ "ovsx": "^0.10.9", "typescript": "latest", "vitest": "latest" + }, + "dependencies": { + "shell-quote": "^1.8.3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2a610a1..6c103a1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,10 @@ settings: importers: .: + dependencies: + shell-quote: + specifier: ^1.8.3 + version: 1.8.3 devDependencies: '@antfu/eslint-config': specifier: ^7.4.3 @@ -14,6 +18,9 @@ importers: '@changesets/cli': specifier: ^2.29.8 version: 2.29.8 + '@types/shell-quote': + specifier: ^1.7.5 + version: 1.7.5 '@types/vscode': specifier: ^1.95.0 version: 1.109.0 @@ -921,6 +928,9 @@ packages: '@types/sarif@2.1.7': resolution: {integrity: sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ==} + '@types/shell-quote@1.7.5': + resolution: {integrity: sha512-+UE8GAGRPbJVQDdxi16dgadcBfQ+KG2vgZhV1+3A1XmHbmwcdwhCUwIdy+d3pAGrbvgRoVSjeI9vOWyq376Yzw==} + '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} @@ -2888,6 +2898,10 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + shell-quote@1.8.3: + resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} + engines: {node: '>= 0.4'} + side-channel-list@1.0.0: resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} engines: {node: '>= 0.4'} @@ -4237,6 +4251,8 @@ snapshots: '@types/sarif@2.1.7': {} + '@types/shell-quote@1.7.5': {} + '@types/unist@3.0.3': {} '@types/vscode@1.109.0': {} @@ -6526,6 +6542,8 @@ snapshots: shebang-regex@3.0.0: {} + shell-quote@1.8.3: {} + side-channel-list@1.0.0: dependencies: es-errors: 1.3.0 diff --git a/src/commands/__tests__/customCommand.test.ts b/src/commands/__tests__/customCommand.test.ts new file mode 100644 index 0000000..2f20d13 --- /dev/null +++ b/src/commands/__tests__/customCommand.test.ts @@ -0,0 +1,166 @@ +import type { CustomCommandConfig } from "../../types.js"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// --- Mocks --- + +const mockSendText = vi.fn(); +const mockShow = vi.fn(); +const mockCreateTerminal = vi.fn(() => ({ sendText: mockSendText, show: mockShow })); +const mockShowQuickPick = vi.fn(async (items: Array) => items[0]); +const mockShowErrorMessage = vi.fn(); +const mockExecuteCommand = vi.fn(); + +vi.mock("vscode", () => ({ + commands: { + executeCommand: (...args: Array) => mockExecuteCommand(...args), + }, + window: { + createTerminal: (...args: Array) => mockCreateTerminal(...args), + showErrorMessage: (...args: Array) => mockShowErrorMessage(...args), + showQuickPick: (...args: Array) => mockShowQuickPick(...args), + }, + workspace: { + getConfiguration: () => ({ get: (_k: string, d: unknown) => d }), + }, +})); + +const mockExistsSync = vi.fn(() => true); +vi.mock("node:fs", () => ({ + existsSync: (...args: Array) => mockExistsSync(...args), +})); + +const mockUnref = vi.fn(); +const mockOn = vi.fn(); +const mockSpawn = vi.fn(() => ({ on: mockOn, unref: mockUnref })); +vi.mock("node:child_process", () => ({ + spawn: (...args: Array) => mockSpawn(...args), +})); + +vi.mock("shell-quote", () => ({ + quote: (args: Array) => + args.map(arg => (arg.includes(" ") ? `'${arg}'` : arg)).join(" "), +})); + +const mockResolveItemContext = vi.fn(() => ({ + cwd: "/repo/feat", + vars: { name: "feat", path: "/repo/feat" }, +})); +vi.mock("../../utils/resolveItemContext.js", () => ({ + resolveItemContext: (...args: Array) => mockResolveItemContext(...args), +})); + +const mockGetCustomCommands = vi.fn<() => Array>(() => []); +vi.mock("../../utils/customCommandConfig.js", () => ({ + getCustomCommands: (...args: Array) => mockGetCustomCommands(...args), +})); + +vi.mock("../../utils/template.js", () => ({ + renderTemplate: (input: string) => input, +})); + +vi.mock("../../utils/outputChannel.js", () => ({ + log: vi.fn(), + logError: vi.fn(), +})); + +const { runCustomCommand } = await import("../customCommand.js"); +const { logError } = await import("../../utils/outputChannel.js"); + +// --- Helpers --- + +function makeItem() { + return { worktreeInfo: { branch: "feat", head: "abc123", isDetached: false, isCurrent: false, isMain: false, isPrunable: false, name: "feat", path: "/repo/feat" } } as const; +} + +// --- Tests --- + +describe("runCustomCommand", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockExistsSync.mockReturnValue(true); + mockResolveItemContext.mockReturnValue({ + cwd: "/repo/feat", + vars: { name: "feat", path: "/repo/feat" }, + }); + }); + + describe("terminal mode", () => { + it("creates terminal with name, cwd, and env, sends command, and shows", async () => { + mockGetCustomCommands.mockReturnValue([ + { command: ["echo", "hello"], env: { FOO: "bar" }, label: "Echo", mode: "terminal" }, + ]); + + await runCustomCommand(makeItem(), "directory"); + + expect(mockCreateTerminal).toHaveBeenCalledWith({ + cwd: "/repo/feat", + env: { FOO: "bar" }, + name: "Echo", + }); + expect(mockSendText).toHaveBeenCalledWith("echo hello"); + expect(mockShow).toHaveBeenCalled(); + }); + + it("quotes arguments with spaces", async () => { + mockGetCustomCommands.mockReturnValue([ + { command: ["echo", "hello world"], env: {}, label: "Echo", mode: "terminal" }, + ]); + + await runCustomCommand(makeItem(), "directory"); + + expect(mockSendText).toHaveBeenCalledWith("echo 'hello world'"); + }); + }); + + describe("spawn mode (default)", () => { + it("calls spawn with correct arguments", async () => { + mockGetCustomCommands.mockReturnValue([ + { command: ["git", "status"], env: {}, label: "Status", mode: "spawn" }, + ]); + + await runCustomCommand(makeItem(), "directory"); + + expect(mockSpawn).toHaveBeenCalledWith( + "git", + ["status"], + expect.objectContaining({ + cwd: "/repo/feat", + detached: true, + stdio: "ignore", + }), + ); + expect(mockUnref).toHaveBeenCalled(); + }); + }); + + describe("terminal mode error handling", () => { + it("calls logError and showErrorMessage when createTerminal throws", async () => { + const error = new Error("terminal failed"); + mockCreateTerminal.mockImplementationOnce(() => { + throw error; + }); + mockGetCustomCommands.mockReturnValue([ + { command: ["echo"], env: {}, label: "Fail", mode: "terminal" }, + ]); + + await runCustomCommand(makeItem(), "directory"); + + expect(logError).toHaveBeenCalledWith("Failed to run 'echo' in terminal", error); + expect(mockShowErrorMessage).toHaveBeenCalledWith("Failed to run 'echo' in terminal."); + }); + }); + + describe("default mode when omitted", () => { + it("behaves as spawn when mode is not specified", async () => { + mockGetCustomCommands.mockReturnValue([ + { command: ["ls", "-la"], env: {}, label: "List", mode: "spawn" }, + ]); + + await runCustomCommand(makeItem(), "directory"); + + expect(mockSpawn).toHaveBeenCalled(); + expect(mockCreateTerminal).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/commands/customCommand.ts b/src/commands/customCommand.ts index 7f8f894..bd4ec98 100644 --- a/src/commands/customCommand.ts +++ b/src/commands/customCommand.ts @@ -5,6 +5,7 @@ import { spawn } from "node:child_process"; import * as fs from "node:fs"; import process from "node:process"; +import { quote } from "shell-quote"; import * as vscode from "vscode"; import { CMD_SHOW_OUTPUT } from "../constants.js"; @@ -50,6 +51,18 @@ function spawnCommand(bin: string, args: Array, env: Record, env: Record, cwd: string): void { + try { + const terminal = vscode.window.createTerminal({ cwd, env, name: label }); + terminal.sendText(quote([bin, ...args])); + terminal.show(); + log(`Custom command (terminal): ${quote([bin, ...args])}`); + } catch (error) { + logError(`Failed to run '${bin}' in terminal`, error); + void vscode.window.showErrorMessage(`Failed to run '${bin}' in terminal.`); + } +} + export async function runCustomCommand( item: TreeActionableItem | undefined, type: "directory" | "workspace", @@ -75,6 +88,7 @@ export async function runCustomCommand( ...rendered, label: cmd.label, detail: `${rendered.bin} ${rendered.args.join(" ")}`, + mode: cmd.mode, }; }); @@ -84,5 +98,9 @@ export async function runCustomCommand( if (!selected) return; - spawnCommand(selected.bin, selected.args, selected.env, resolved.cwd); + if (selected.mode === "terminal") { + terminalCommand(selected.label, selected.bin, selected.args, selected.env, resolved.cwd); + } else { + spawnCommand(selected.bin, selected.args, selected.env, resolved.cwd); + } } diff --git a/src/types.ts b/src/types.ts index 5e20045..a210163 100644 --- a/src/types.ts +++ b/src/types.ts @@ -30,4 +30,5 @@ export interface CustomCommandConfig { label: string; command: Array; env?: Record; + mode?: "spawn" | "terminal"; } diff --git a/src/utils/__tests__/customCommandConfig.test.ts b/src/utils/__tests__/customCommandConfig.test.ts index 476bca4..fe52066 100644 --- a/src/utils/__tests__/customCommandConfig.test.ts +++ b/src/utils/__tests__/customCommandConfig.test.ts @@ -62,4 +62,20 @@ describe("validateCustomCommand", () => { it("returns true when env is omitted", () => { expect(validateCustomCommand({ label: "Test", command: ["echo"] })).toBe(true); }); + + it("returns true when mode is 'spawn'", () => { + expect(validateCustomCommand({ label: "Test", command: ["echo"], mode: "spawn" })).toBe(true); + }); + + it("returns true when mode is 'terminal'", () => { + expect(validateCustomCommand({ label: "Test", command: ["echo"], mode: "terminal" })).toBe(true); + }); + + it("returns true when mode is omitted", () => { + expect(validateCustomCommand({ label: "Test", command: ["echo"] })).toBe(true); + }); + + it("returns false when mode is invalid", () => { + expect(validateCustomCommand({ label: "Test", command: ["echo"], mode: "invalid" })).toBe(false); + }); }); diff --git a/src/utils/customCommandConfig.ts b/src/utils/customCommandConfig.ts index 0ec265f..eb45aab 100644 --- a/src/utils/customCommandConfig.ts +++ b/src/utils/customCommandConfig.ts @@ -18,6 +18,8 @@ export function validateCustomCommand(entry: unknown): entry is CustomCommandCon if (!Object.values(obj.env).every((v: unknown) => typeof v === "string")) return false; } + if (obj.mode !== undefined && obj.mode !== "spawn" && obj.mode !== "terminal") return false; + return true; } @@ -28,7 +30,7 @@ export function getCustomCommands(type: "directory" | "workspace"): Array