diff --git a/.changeset/add-open-in-terminal.md b/.changeset/add-open-in-terminal.md new file mode 100644 index 0000000..2fe22d7 --- /dev/null +++ b/.changeset/add-open-in-terminal.md @@ -0,0 +1,12 @@ +--- +"git-work-grove": minor +--- + +Add Open in Terminal command for worktrees and workspace files + +- New context menu and inline button to open a terminal at any item's location +- Terminal CWD resolves to the worktree directory (or parent directory for workspace files) +- Customizable terminal name templates for all 4 item types +- Added "terminal" option to the openBehavior setting +- Leaf worktrees/repository (no workspace files) now respond to clicks via openBehavior +- QuickPick includes "Open in Terminal" and "Always Open in Terminal" options diff --git a/CLAUDE.md b/CLAUDE.md index 3921417..45deea1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,6 +23,7 @@ Design specs live in `docs/spec/`. These are the **source of truth** for how fea | `docs/spec/commands.md` | All commands, menu placement, behaviors | | `docs/spec/workspace-scanning.md` | File discovery, include/exclude patterns, limits | | `docs/spec/open-behavior.md` | Open modes, URI resolution, click handling | +| `docs/spec/open-in-terminal.md` | CWD resolution, terminal naming, prunable guard | | `docs/spec/empty-states.md` | Git unavailable, no repository, no worktrees messages | ## The 4 Fundamental Types diff --git a/README.md b/README.md index b4bf813..5dfc553 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ If VS Code or GitLens ever ships proper `.code-workspace` support for worktrees, - **Favorites** — Pin any item (repository, worktree, workspace file) to the top with drag-and-drop reordering - **Current indicator** — Green icon and badge highlight the currently open item - **Customizable templates** — Full control over labels and descriptions for all 8 item types +- **Open in Terminal** — Right-click to open a terminal at any worktree or workspace file location - **Prune** — Clean up stale worktree records - **Live updates** — FileSystemWatcher detects worktree changes automatically @@ -59,6 +60,7 @@ Right-click any item in the tree to access: |--------|-------------| | **Open in New Window** | Opens the worktree or workspace file in a new VS Code window | | **Open in Current Window** | Opens in the current VS Code window | +| **Open in Terminal** | Opens a terminal at the item's location | | **Reveal in Finder** | Opens the item's location in your OS file manager | | **Copy Name** | Copy the item's display name to clipboard | | **Copy Path** | Copy the item's filesystem path to clipboard | @@ -67,6 +69,7 @@ Favorite-specific actions appear as inline buttons: | Button | Description | |--------|-------------| +| **Open in Terminal** (terminal icon) | Open a terminal at this item's location (non-favorites only) | | **Add Favorite** (star outline) | Pin this item to the Favorites section | | **Remove Favorite** (filled star) | Unpin from Favorites | | **Move Up / Move Down** (chevrons) | Reorder within Favorites | @@ -87,9 +90,10 @@ The WORKGROVE panel header provides: When you click a workspace file, the behavior depends on the `openBehavior` setting: -- **`ask`** (default) — Shows a picker with options: _Open in New Window_, _Open in Current Window_, plus "Always" variants that persist your choice +- **`ask`** (default) — Shows a picker with options: _Open in New Window_, _Open in Current Window_, _Open in Terminal_, plus "Always" variants that persist your choice - **`newWindow`** — Always opens in a new window - **`currentWindow`** — Always opens in the current window +- **`terminal`** — Always opens a terminal at the item's location ### Current Indicator @@ -106,10 +110,10 @@ Open VS Code Settings (`Cmd+,` / `Ctrl+,`) and search for `git-work-grove`: | Setting | Type | Default | Description | |---------|------|---------|-------------| -| `git-work-grove.openBehavior` | `ask` \| `newWindow` \| `currentWindow` | `ask` | Default action when opening a workspace | +| `git-work-grove.openBehavior` | `ask` \| `newWindow` \| `currentWindow` \| `terminal` | `ask` | Default action when opening a workspace | | `git-work-grove.workspaceFile.include` | `string[]` | `["*.code-workspace"]` | Glob patterns for workspace file scanning | | `git-work-grove.workspaceFile.exclude` | `string[]` | `[]` | Glob patterns to exclude from scanning | -| `git-work-grove.template.*` | `string` | *(varies)* | Display templates — see [Template Customization](https://github.com/vp-tw/vscode-extension-git-work-grove/blob/main/docs/templates.md) | +| `git-work-grove.template.*` | `string` | *(varies)* | Display templates (label, description, terminalName) — see [Template Customization](https://github.com/vp-tw/vscode-extension-git-work-grove/blob/main/docs/templates.md) | | `git-work-grove.favorites` | `string[]` | `[]` | Ordered list of favorited item paths (managed via the UI) | ### Template Customization @@ -139,6 +143,7 @@ These commands appear when right-clicking items in the tree view: |---------|-------------| | Open in New Window | Open worktree or workspace file in a new VS Code window | | Open in Current Window | Open in the current VS Code window | +| Open in Terminal | Open a terminal at the item's location | | Reveal in Finder | Open the item's location in your OS file manager | | Copy Name | Copy the item's display name to clipboard | | Copy Path | Copy the item's filesystem path to clipboard | @@ -161,6 +166,7 @@ Design documents for contributors and AI-assisted development: - [Commands](https://github.com/vp-tw/vscode-extension-git-work-grove/blob/main/docs/spec/commands.md) — All commands, menu placement, behaviors - [Workspace Scanning](https://github.com/vp-tw/vscode-extension-git-work-grove/blob/main/docs/spec/workspace-scanning.md) — File discovery, include/exclude patterns - [Open Behavior](https://github.com/vp-tw/vscode-extension-git-work-grove/blob/main/docs/spec/open-behavior.md) — Open modes, URI resolution, click handling +- [Open in Terminal](https://github.com/vp-tw/vscode-extension-git-work-grove/blob/main/docs/spec/open-in-terminal.md) — CWD resolution, terminal naming, prunable guard - [Empty States](https://github.com/vp-tw/vscode-extension-git-work-grove/blob/main/docs/spec/empty-states.md) — Git unavailable, no repository, no worktrees messages ## Installation diff --git a/docs/spec/commands.md b/docs/spec/commands.md index c30410c..8a769eb 100644 --- a/docs/spec/commands.md +++ b/docs/spec/commands.md @@ -15,6 +15,7 @@ All commands use the `gitWorkGrove.*` prefix. | `moveFavoriteDown` | Move Favorite Down | `$(chevron-down)` | `gitWorkGrove.hasRepository` | | `copyName` | Copy Name | — | `gitWorkGrove.hasRepository` | | `copyPath` | Copy Path | — | `gitWorkGrove.hasRepository` | +| `openInTerminal` | Open in Terminal | `$(terminal)` | `gitWorkGrove.hasRepository` | | `revealInOS` | Reveal in Finder | — | `gitWorkGrove.hasRepository` | | `refresh` | Refresh | `$(refresh)` | `gitWorkGrove.hasRepository` | | `showOutput` | Show Output | — | *(always)* | @@ -37,8 +38,10 @@ All commands use the `gitWorkGrove.*` prefix. | `openInNewWindow` | `navigation@1` | `viewItem =~ /^worktree\|^workspaceFile\|^repository\|^favorite\./` | | `openInCurrentWindow` | `navigation@2` | *(same)* | | `revealInOS` | `navigation@3` | *(same)* | +| `openInTerminal` | `navigation@4` | *(same)* | | `copyName` | `5_cutcopypaste@1` | *(same)* | | `copyPath` | `5_cutcopypaste@2` | *(same)* | +| `openInTerminal` | `inline` | `viewItem =~ /^worktree\|^workspaceFile\|^repository/` AND NOT `viewItem =~ /favorite/` | | `addFavorite` | `inline` | `viewItem =~ /^worktree\|^workspaceFile\|^repository/` AND NOT `viewItem =~ /favorite/` | | `removeFavorite` | `inline` | `viewItem =~ /favorite/` | | `moveFavoriteUp` | `inline` | `viewItem =~ /^favorite\./` | @@ -56,16 +59,30 @@ All open commands resolve a URI from the tree item: `openInNewWindow` always opens in a new window. `openInCurrentWindow` always opens in the current window. +### Open in Terminal + +Opens a new VS Code terminal at the item's location. See [Open in Terminal spec](open-in-terminal.md) for full details. + +- `WorktreeItem` / `GroupHeaderItem` (repository) → cwd is `worktreeInfo.path` +- `WorkspaceFileItem` → cwd is `dirname(workspaceFileInfo.path)` +- `FavoriteItem` → resolved via duck-typing (same detection order as `resolveUri`) + +Terminal name is rendered from `template.*.terminalName` settings (4 templates — favorites reuse non-favorite templates). + +Prunable worktree guard: checks `fs.existsSync(cwd)` before creating the terminal. Shows a warning if the directory is missing. + ### Default Open (click) Handled by `treeView.onDidChangeSelection`, not a registered command. Triggers on: - `FavoriteItem` click (when not current) - `WorkspaceFileItem` click (when not current) +- `WorktreeItem` click (leaf only — `CollapsibleState.None`, i.e., no workspace files) +- `GroupHeaderItem` (repository) click (leaf only — `CollapsibleState.None`, i.e., no workspace files) -Uses `openBehavior` setting: `"ask"` shows a QuickPick, `"newWindow"` / `"currentWindow"` opens directly. The QuickPick includes "Always" options that persist the choice. +Uses `openBehavior` setting: `"ask"` shows a QuickPick, `"newWindow"` / `"currentWindow"` / `"terminal"` opens directly. The QuickPick includes "Always" options that persist the choice. -WorktreeItem clicks are NOT handled — clicking a worktree expands/collapses it. +WorktreeItem and GroupHeaderItem (repository) clicks with children expand/collapse only (no open). ### Copy Name / Copy Path diff --git a/docs/spec/open-behavior.md b/docs/spec/open-behavior.md index bb04251..6f5bc1a 100644 --- a/docs/spec/open-behavior.md +++ b/docs/spec/open-behavior.md @@ -6,18 +6,20 @@ Controls how items are opened when clicked or via context menu commands. | Setting | Type | Default | Scope | |---|---|---|---| -| `git-work-grove.openBehavior` | `"ask" \| "newWindow" \| "currentWindow"` | `"ask"` | resource | +| `git-work-grove.openBehavior` | `"ask" \| "newWindow" \| "currentWindow" \| "terminal"` | `"ask"` | resource | ## Modes ### `"ask"` (default) -Shows a QuickPick with 4 options: +Shows a QuickPick with 6 options: 1. **Open in New Window** — opens once in new window 2. **Open in Current Window** — opens once in current window -3. **Always Open in New Window** — persists `"newWindow"` to global settings, then opens -4. **Always Open in Current Window** — persists `"currentWindow"` to global settings, then opens +3. **Open in Terminal** — opens once in terminal +4. **Always Open in New Window** — persists `"newWindow"` to global settings, then opens +5. **Always Open in Current Window** — persists `"currentWindow"` to global settings, then opens +6. **Always Open in Terminal** — persists `"terminal"` to global settings, then opens terminal The "Always" options update the setting globally, so future clicks use the new mode. @@ -29,15 +31,23 @@ Opens the target in a new VS Code window (`forceNewWindow: true`). Opens the target in the current VS Code window (`forceNewWindow: false`). +### `"terminal"` + +Opens a new VS Code terminal at the item's location instead of opening a window. See [Open in Terminal spec](open-in-terminal.md) for CWD resolution and terminal naming details. + ## Trigger Points | Trigger | Behavior | |---|---| | Click on `WorkspaceFileItem` | Uses `openBehavior` setting | | Click on `FavoriteItem` | Uses `openBehavior` setting | -| Click on `WorktreeItem` | Expands/collapses only (no open) | +| Click on `WorktreeItem` (has children) | Expands/collapses only (no open) | +| Click on `WorktreeItem` (leaf, no workspace files) | Uses `openBehavior` setting | +| Click on `GroupHeaderItem` (repository, has children) | Expands/collapses only (no open) | +| Click on `GroupHeaderItem` (repository, leaf, no workspace files) | Uses `openBehavior` setting | | Context menu "Open in New Window" | Always new window (ignores setting) | | Context menu "Open in Current Window" | Always current window (ignores setting) | +| Context menu "Open in Terminal" | Always opens terminal (ignores setting) | ## URI Resolution diff --git a/docs/spec/open-in-terminal.md b/docs/spec/open-in-terminal.md new file mode 100644 index 0000000..d5e8908 --- /dev/null +++ b/docs/spec/open-in-terminal.md @@ -0,0 +1,69 @@ +# Open in Terminal Specification + +Opens a new VS Code terminal at the item's filesystem location. + +## Command + +| Command ID | Title | Icon | +|---|---|---| +| `gitWorkGrove.openInTerminal` | Open in Terminal | `$(terminal)` | + +## CWD Resolution + +The working directory depends on the item type: + +| Item type | CWD | +|---|---| +| `GroupHeaderItem` (repository) | `worktreeInfo.path` | +| `WorktreeItem` | `worktreeInfo.path` | +| `WorkspaceFileItem` | `dirname(workspaceFileInfo.path)` | +| `FavoriteItem` | Resolved via duck-typing (see detection order below) | + +### Detection Order (duck-typing) + +Same order as `resolveUri` in `open-behavior.md`: + +1. `"favoritePath" in item` → resolve to underlying type +2. `"workspaceFileInfo" in item` → `dirname(workspaceFileInfo.path)` +3. `"worktreeInfo" in item && item.worktreeInfo` → `worktreeInfo.path` + +## Terminal Naming + +The terminal name is rendered from `template.*.terminalName` settings. Only 4 templates exist (one per non-favorite type). Favorites reuse the corresponding non-favorite template. + +| Item type | Template setting | +|---|---| +| Repository | `template.repository.terminalName` | +| Worktree | `template.worktree.terminalName` | +| Repository Workspace | `template.repositoryWorkspace.terminalName` | +| Worktree Workspace | `template.worktreeWorkspace.terminalName` | + +### Defaults + +| Setting | Default | +|---|---| +| `template.repository.terminalName` | `{ref}` | +| `template.worktree.terminalName` | `{ref}` | +| `template.repositoryWorkspace.terminalName` | `{name}` | +| `template.worktreeWorkspace.terminalName` | `{name} ({ref})` | + +Templates use the same variables and syntax as label/description templates. See [templates.md](templates.md) for variable reference. + +## Prunable Worktree Guard + +Before creating the terminal, checks `fs.existsSync(cwd)`. If the directory does not exist (prunable worktree), shows a warning message instead of creating the terminal. + +## Error Handling + +The `createTerminal` call is wrapped in a try/catch. On failure, shows an error message via `showErrorMessage` and logs the error via `logError`. + +## Integration with openBehavior + +The `openBehavior` setting now accepts `"terminal"` as a fourth option. When set, clicking an item (at trigger points listed in [open-behavior.md](open-behavior.md)) opens a terminal instead of a window. + +Leaf worktree and repository items (`CollapsibleState.None`, i.e., no workspace files found) now trigger `openBehavior` on click. Previously these clicks were inert (only expand/collapse applied, and leaf items had nothing to expand). + +## Menu Placement + +- **Context menu**: `navigation@4` — appears for all actionable item types +- **Inline button**: `$(terminal)` icon on non-favorite items only (favorites already have 3 inline buttons: remove, move up, move down) diff --git a/docs/spec/templates.md b/docs/spec/templates.md index 5510ed5..e09c1a1 100644 --- a/docs/spec/templates.md +++ b/docs/spec/templates.md @@ -17,7 +17,7 @@ Based on the 4 fundamental cases (repository, worktree, repositoryWorkspace, wor | 7 | Favorite Repo Workspace | `template.favoriteRepositoryWorkspace` | Favorited workspace file from repository | | 8 | Favorite Worktree Workspace | `template.favoriteWorktreeWorkspace` | Favorited workspace file from linked worktree | -Each type has `.label` and `.description` settings (16 settings total). +Each type has `.label` and `.description` settings (16 settings total). Additionally, the 4 non-favorite types have a `.terminalName` setting (4 settings, 20 total). Favorite types reuse the corresponding non-favorite `terminalName` template. ## Template Rendering @@ -109,6 +109,10 @@ Built by `workspaceFileVars(name, filePath, parent)` where `parent.isMain === fa | `template.favoriteRepositoryWorkspace.description` | *(empty)* | | `template.favoriteWorktreeWorkspace.label` | `{name}` | | `template.favoriteWorktreeWorkspace.description` | `🌲 {worktree}` | +| `template.repository.terminalName` | `Repository` | +| `template.worktree.terminalName` | `{name}` | +| `template.repositoryWorkspace.terminalName` | `{name}` | +| `template.worktreeWorkspace.terminalName` | `{name}` | Design rationale: Only `favoriteWorktreeWorkspace.description` has a non-empty default because it's the only type that needs disambiguation — a workspace file shortcut in the Favorites section has no visible parent to indicate which worktree it belongs to. @@ -134,6 +138,16 @@ else → favoriteWorktreeWorkspace.label / .description For `type === "repo"` / `"worktree"`: Uses the corresponding `favoriteRepository.*` / `favoriteWorktree.*` templates directly. +### Terminal Name (all types) + +Terminal name templates use the non-favorite variant only. Favorites resolve to their underlying type: + +``` +FavoriteItem (type === "repo") → repository.terminalName +FavoriteItem (type === "worktree") → worktree.terminalName +FavoriteItem (type === "workspaceFile") → repositoryWorkspace.terminalName or worktreeWorkspace.terminalName (based on parent.isMain) +``` + ### Repository header (GroupHeaderItem) Always uses `repository.label` / `repository.description` with `worktreeVars(main)`. diff --git a/docs/spec/tree-structure.md b/docs/spec/tree-structure.md index d6f89ac..308871f 100644 --- a/docs/spec/tree-structure.md +++ b/docs/spec/tree-structure.md @@ -104,6 +104,10 @@ Note: "Has children" means at least one workspace file exists in the worktree di Green color: `new vscode.ThemeColor("terminal.ansiGreen")` +## Inline Buttons + +Non-favorite items show two inline buttons: `$(terminal)` (Open in Terminal) and `$(star-empty)` (Add Favorite). Favorite items show three inline buttons: `$(star-full)` (Remove Favorite), `$(chevron-up)` (Move Up), `$(chevron-down)` (Move Down). See [commands.md](commands.md) for full menu placement details. + ## Tooltips All items use a unified tooltip format via `buildTooltip()` (`src/utils/tooltip.ts`). Fields shown in order: diff --git a/docs/templates.md b/docs/templates.md index f0c273a..f9d145e 100644 --- a/docs/templates.md +++ b/docs/templates.md @@ -17,7 +17,7 @@ Based on the 4 fundamental cases (repository, worktree, repositoryWorkspace, wor | 7 | `template.favoriteRepositoryWorkspace` | Favorited workspace file from Repository | | 8 | `template.favoriteWorktreeWorkspace` | Favorited workspace file from linked worktree | -Each has `.label` and `.description`. Set to empty string to hide. +Each has `.label` and `.description`. Set to empty string to hide. The 4 non-favorite types also have a `.terminalName` setting for the Open in Terminal command. Favorites reuse the corresponding non-favorite `terminalName` template. ## Settings @@ -39,6 +39,10 @@ Each has `.label` and `.description`. Set to empty string to hide. | `template.favoriteRepositoryWorkspace.description` | *(empty)* | | `template.favoriteWorktreeWorkspace.label` | `{name}` | | `template.favoriteWorktreeWorkspace.description` | `🌲 {worktree}` | +| `template.repository.terminalName` | `Repository` | +| `template.worktree.terminalName` | `{name}` | +| `template.repositoryWorkspace.terminalName` | `{name}` | +| `template.worktreeWorkspace.terminalName` | `{name}` | ## Variables diff --git a/package.json b/package.json index 609948d..bc681c7 100644 --- a/package.json +++ b/package.json @@ -100,6 +100,12 @@ "title": "Git WorkGrove: Copy Path", "enablement": "gitWorkGrove.hasRepository" }, + { + "command": "gitWorkGrove.openInTerminal", + "title": "Git WorkGrove: Open in Terminal", + "icon": "$(terminal)", + "enablement": "gitWorkGrove.hasRepository" + }, { "command": "gitWorkGrove.revealInOS", "title": "Git WorkGrove: Reveal in Finder", @@ -167,6 +173,11 @@ "when": "view == gitWorkGrove.worktrees && viewItem =~ /^worktree|^workspaceFile|^repository|^favorite\\./", "group": "navigation@3" }, + { + "command": "gitWorkGrove.openInTerminal", + "when": "view == gitWorkGrove.worktrees && viewItem =~ /^worktree|^workspaceFile|^repository|^favorite\\./", + "group": "navigation@4" + }, { "command": "gitWorkGrove.copyName", "when": "view == gitWorkGrove.worktrees && viewItem =~ /^worktree|^workspaceFile|^repository|^favorite\\./", @@ -177,6 +188,11 @@ "when": "view == gitWorkGrove.worktrees && viewItem =~ /^worktree|^workspaceFile|^repository|^favorite\\./", "group": "5_cutcopypaste@2" }, + { + "command": "gitWorkGrove.openInTerminal", + "when": "view == gitWorkGrove.worktrees && viewItem =~ /^worktree|^workspaceFile|^repository/ && !(viewItem =~ /favorite/)", + "group": "inline" + }, { "command": "gitWorkGrove.addFavorite", "when": "view == gitWorkGrove.worktrees && viewItem =~ /^worktree|^workspaceFile|^repository/ && !(viewItem =~ /favorite/)", @@ -207,7 +223,14 @@ "enum": [ "ask", "newWindow", - "currentWindow" + "currentWindow", + "terminal" + ], + "enumDescriptions": [ + "Show a picker to choose how to open", + "Always open in a new window", + "Always open in the current window", + "Always open a terminal at the item location" ], "default": "ask", "description": "Default action when opening a workspace.", @@ -245,6 +268,12 @@ "markdownDescription": "Description template for the Repository header. Variables: `{name}`, `{branch}`, `{ref}`, `{head}`, `{path}`. Supports `{key|fallback}` and `{?key}...{/key}` syntax.", "scope": "resource" }, + "git-work-grove.template.repository.terminalName": { + "type": "string", + "default": "{ref}", + "markdownDescription": "Terminal name template for Repository. Variables: `{name}`, `{branch}`, `{ref}`, `{head}`, `{path}`. Supports `{key|fallback}` and `{?key}...{/key}` syntax.", + "scope": "resource" + }, "git-work-grove.template.worktree.label": { "type": "string", "default": "{name}", @@ -257,6 +286,12 @@ "markdownDescription": "Description template for linked worktrees. Variables: `{name}`, `{branch}`, `{ref}`, `{head}`, `{path}`. Supports `{key|fallback}` and `{?key}...{/key}` syntax.", "scope": "resource" }, + "git-work-grove.template.worktree.terminalName": { + "type": "string", + "default": "{ref}", + "markdownDescription": "Terminal name template for linked worktrees. Variables: `{name}`, `{branch}`, `{ref}`, `{head}`, `{path}`. Supports `{key|fallback}` and `{?key}...{/key}` syntax.", + "scope": "resource" + }, "git-work-grove.template.repositoryWorkspace.label": { "type": "string", "default": "{name}", @@ -269,6 +304,12 @@ "markdownDescription": "Description template for workspace files under Repository. Variables: `{name}`, `{branch}`, `{ref}`, `{head}`, `{path}`. Supports `{key|fallback}` and `{?key}...{/key}` syntax.", "scope": "resource" }, + "git-work-grove.template.repositoryWorkspace.terminalName": { + "type": "string", + "default": "{name}", + "markdownDescription": "Terminal name template for workspace files under Repository. Variables: `{name}`, `{branch}`, `{ref}`, `{head}`, `{path}`, `{worktree}`. Supports `{key|fallback}` and `{?key}...{/key}` syntax.", + "scope": "resource" + }, "git-work-grove.template.worktreeWorkspace.label": { "type": "string", "default": "{name}", @@ -281,6 +322,12 @@ "markdownDescription": "Description template for workspace files under linked worktrees. Variables: `{name}`, `{branch}`, `{ref}`, `{head}`, `{path}`, `{worktree}`. Supports `{key|fallback}` and `{?key}...{/key}` syntax.", "scope": "resource" }, + "git-work-grove.template.worktreeWorkspace.terminalName": { + "type": "string", + "default": "{name} ({ref})", + "markdownDescription": "Terminal name template for workspace files under linked worktrees. Variables: `{name}`, `{branch}`, `{ref}`, `{head}`, `{path}`, `{worktree}`. Supports `{key|fallback}` and `{?key}...{/key}` syntax.", + "scope": "resource" + }, "git-work-grove.template.favoriteRepository.label": { "type": "string", "default": "Repository", diff --git a/src/commands/__tests__/openTerminal.test.ts b/src/commands/__tests__/openTerminal.test.ts new file mode 100644 index 0000000..7cf173e --- /dev/null +++ b/src/commands/__tests__/openTerminal.test.ts @@ -0,0 +1,242 @@ +import type { WorktreeInfo } from "../../types.js"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// --- Mocks --- + +const mockShow = vi.fn(); +const mockCreateTerminal = vi.fn(() => ({ show: mockShow })); +const mockShowErrorMessage = vi.fn(); + +vi.mock("vscode", () => ({ + window: { + createTerminal: (...args: Array) => mockCreateTerminal(...args), + showErrorMessage: (...args: Array) => mockShowErrorMessage(...args), + }, +})); + +const mockExistsSync = vi.fn(() => true); +vi.mock("node:fs", () => ({ + existsSync: (...args: Array) => mockExistsSync(...args), +})); + +const mockGetRepositoryTerminalName = vi.fn(() => "repo-terminal:{ref}"); +const mockGetWorktreeTerminalName = vi.fn(() => "wt-terminal:{ref}"); +const mockGetRepositoryWorkspaceTerminalName = vi.fn(() => "repo-ws-terminal:{name}"); +const mockGetWorktreeWorkspaceTerminalName = vi.fn(() => "wt-ws-terminal:{name} ({ref})"); + +vi.mock("../../utils/template.js", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + getRepositoryTerminalName: () => mockGetRepositoryTerminalName(), + getWorktreeTerminalName: () => mockGetWorktreeTerminalName(), + getRepositoryWorkspaceTerminalName: () => mockGetRepositoryWorkspaceTerminalName(), + getWorktreeWorkspaceTerminalName: () => mockGetWorktreeWorkspaceTerminalName(), + }; +}); + +vi.mock("../../utils/outputChannel.js", () => ({ + logError: vi.fn(), +})); + +const { openInTerminal } = await import("../openTerminal.js"); +const { logError } = await import("../../utils/outputChannel.js"); + +// --- Helpers --- + +function makeWorktreeInfo(overrides?: Partial): WorktreeInfo { + return { + name: "feat-auth", + path: "/repo/feat-auth", + head: "abc12345def67890", + branch: "feat/auth", + isDetached: false, + isCurrent: false, + isMain: false, + isPrunable: false, + ...overrides, + }; +} + +// --- Tests --- + +describe("openInTerminal", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockExistsSync.mockReturnValue(true); + }); + + describe("worktreeItem", () => { + it("opens terminal for a linked worktree", () => { + const info = makeWorktreeInfo(); + openInTerminal({ worktreeInfo: info }); + + expect(mockExistsSync).toHaveBeenCalledWith("/repo/feat-auth"); + expect(mockCreateTerminal).toHaveBeenCalledWith({ + name: "wt-terminal:feat/auth", + cwd: "/repo/feat-auth", + }); + expect(mockShow).toHaveBeenCalled(); + }); + + it("opens terminal for repository (main worktree)", () => { + const info = makeWorktreeInfo({ isMain: true, name: "my-project", path: "/repo/main", branch: "main" }); + openInTerminal({ worktreeInfo: info }); + + expect(mockExistsSync).toHaveBeenCalledWith("/repo/main"); + expect(mockCreateTerminal).toHaveBeenCalledWith({ + name: "repo-terminal:main", + cwd: "/repo/main", + }); + expect(mockShow).toHaveBeenCalled(); + }); + }); + + describe("workspaceFileItem", () => { + it("opens terminal for workspace file under repository", () => { + const parent = makeWorktreeInfo({ isMain: true, branch: "main" }); + openInTerminal({ + workspaceFileInfo: { name: "dev", path: "/repo/main/.vscode/dev.code-workspace" }, + parentWorktreeInfo: parent, + }); + + expect(mockExistsSync).toHaveBeenCalledWith("/repo/main/.vscode"); + expect(mockCreateTerminal).toHaveBeenCalledWith({ + name: "repo-ws-terminal:dev", + cwd: "/repo/main/.vscode", + }); + expect(mockShow).toHaveBeenCalled(); + }); + + it("opens terminal for workspace file under linked worktree", () => { + const parent = makeWorktreeInfo({ isMain: false, branch: "feat/auth" }); + openInTerminal({ + workspaceFileInfo: { name: "dev", path: "/repo/feat-auth/dev.code-workspace" }, + parentWorktreeInfo: parent, + }); + + expect(mockExistsSync).toHaveBeenCalledWith("/repo/feat-auth"); + expect(mockCreateTerminal).toHaveBeenCalledWith({ + name: "wt-ws-terminal:dev (feat/auth)", + cwd: "/repo/feat-auth", + }); + expect(mockShow).toHaveBeenCalled(); + }); + }); + + describe("favoriteItem", () => { + it("opens terminal for favorite repo", () => { + const info = makeWorktreeInfo({ isMain: true, branch: "main", path: "/repo/main" }); + openInTerminal({ + favoritePath: "/repo/main", + favoriteType: "repo", + worktreeInfo: info, + }); + + expect(mockExistsSync).toHaveBeenCalledWith("/repo/main"); + expect(mockCreateTerminal).toHaveBeenCalledWith({ + name: "repo-terminal:main", + cwd: "/repo/main", + }); + expect(mockShow).toHaveBeenCalled(); + }); + + it("opens terminal for favorite worktree", () => { + const info = makeWorktreeInfo({ branch: "feat/auth" }); + openInTerminal({ + favoritePath: "/repo/feat-auth", + favoriteType: "worktree", + worktreeInfo: info, + }); + + expect(mockExistsSync).toHaveBeenCalledWith("/repo/feat-auth"); + expect(mockCreateTerminal).toHaveBeenCalledWith({ + name: "wt-terminal:feat/auth", + cwd: "/repo/feat-auth", + }); + expect(mockShow).toHaveBeenCalled(); + }); + + it("opens terminal for favorite workspace file under repository", () => { + const parent = makeWorktreeInfo({ isMain: true, branch: "main" }); + openInTerminal({ + favoritePath: "/repo/main/project.code-workspace", + favoriteType: "workspaceFile", + parentWorktreeInfo: parent, + }); + + expect(mockExistsSync).toHaveBeenCalledWith("/repo/main"); + expect(mockCreateTerminal).toHaveBeenCalledWith({ + name: "repo-ws-terminal:project", + cwd: "/repo/main", + }); + expect(mockShow).toHaveBeenCalled(); + }); + + it("opens terminal for favorite workspace file under linked worktree", () => { + const parent = makeWorktreeInfo({ isMain: false, branch: "feat/auth" }); + openInTerminal({ + favoritePath: "/repo/feat-auth/project.code-workspace", + favoriteType: "workspaceFile", + parentWorktreeInfo: parent, + }); + + expect(mockExistsSync).toHaveBeenCalledWith("/repo/feat-auth"); + expect(mockCreateTerminal).toHaveBeenCalledWith({ + name: "wt-ws-terminal:project (feat/auth)", + cwd: "/repo/feat-auth", + }); + expect(mockShow).toHaveBeenCalled(); + }); + }); + + describe("groupHeaderItem (repository)", () => { + it("opens terminal using worktreeInfo for group header", () => { + const info = makeWorktreeInfo({ isMain: true, name: "my-project", path: "/repo/main", branch: "main" }); + openInTerminal({ worktreeInfo: info }); + + expect(mockExistsSync).toHaveBeenCalledWith("/repo/main"); + expect(mockCreateTerminal).toHaveBeenCalledWith({ + name: "repo-terminal:main", + cwd: "/repo/main", + }); + expect(mockShow).toHaveBeenCalled(); + }); + }); + + describe("error handling", () => { + it("shows error message when CWD does not exist", () => { + mockExistsSync.mockReturnValue(false); + const info = makeWorktreeInfo({ path: "/repo/gone" }); + openInTerminal({ worktreeInfo: info }); + + expect(mockExistsSync).toHaveBeenCalledWith("/repo/gone"); + expect(mockShowErrorMessage).toHaveBeenCalledWith( + "Cannot open terminal: directory does not exist — /repo/gone", + ); + expect(mockCreateTerminal).not.toHaveBeenCalled(); + }); + + it("shows error message and logs when createTerminal throws", () => { + const error = new Error("terminal creation failed"); + mockCreateTerminal.mockImplementationOnce(() => { + throw error; + }); + const info = makeWorktreeInfo(); + openInTerminal({ worktreeInfo: info }); + + expect(mockShowErrorMessage).toHaveBeenCalledWith("Failed to create terminal."); + expect(logError).toHaveBeenCalledWith("Failed to create terminal", error); + expect(mockShow).not.toHaveBeenCalled(); + }); + + it("does nothing when item is undefined", () => { + openInTerminal(undefined as any); + + expect(mockExistsSync).not.toHaveBeenCalled(); + expect(mockCreateTerminal).not.toHaveBeenCalled(); + expect(mockShowErrorMessage).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/commands/openTerminal.ts b/src/commands/openTerminal.ts new file mode 100644 index 0000000..d8d58b7 --- /dev/null +++ b/src/commands/openTerminal.ts @@ -0,0 +1,94 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; + +import * as vscode from "vscode"; + +import { logError } from "../utils/outputChannel.js"; +import { + getRepositoryTerminalName, + getRepositoryWorkspaceTerminalName, + getWorktreeTerminalName, + getWorktreeWorkspaceTerminalName, + renderTemplate, + workspaceFileVars, + worktreeVars, +} from "../utils/template.js"; + +type TerminalOpenableItem = + | { favoritePath: string; favoriteType: "repo" | "worktree" | "workspaceFile"; worktreeInfo?: { isMain: boolean; name: string; path: string; head: string; branch: string | undefined; isDetached: boolean; isCurrent: boolean; isPrunable: boolean }; parentWorktreeInfo?: { isMain: boolean; name: string; path: string; head: string; branch: string | undefined; isDetached: boolean; isCurrent: boolean; isPrunable: boolean } } + | { workspaceFileInfo: { name: string; path: string }; parentWorktreeInfo: { isMain: boolean; name: string; path: string; head: string; branch: string | undefined; isDetached: boolean; isCurrent: boolean; isPrunable: boolean } } + | { worktreeInfo: { isMain: boolean; name: string; path: string; head: string; branch: string | undefined; isDetached: boolean; isCurrent: boolean; isPrunable: boolean } }; + +function resolve(item: TerminalOpenableItem): { cwd: string; name: string } | undefined { + // FavoriteItem + if ("favoritePath" in item) { + switch (item.favoriteType) { + case "repo": { + const info = item.worktreeInfo!; + const name = renderTemplate(getRepositoryTerminalName(), worktreeVars(info)); + return { cwd: item.favoritePath, name }; + } + case "worktree": { + const info = item.worktreeInfo!; + const name = renderTemplate(getWorktreeTerminalName(), worktreeVars(info)); + return { cwd: item.favoritePath, name }; + } + case "workspaceFile": { + const wsName = path.basename(item.favoritePath, ".code-workspace"); + const parent = item.parentWorktreeInfo; + const template = parent?.isMain + ? getRepositoryWorkspaceTerminalName() + : getWorktreeWorkspaceTerminalName(); + const vars = workspaceFileVars(wsName, item.favoritePath, parent); + const name = renderTemplate(template, vars); + return { cwd: path.dirname(item.favoritePath), name }; + } + } + } + + // WorkspaceFileItem + if ("workspaceFileInfo" in item) { + const parent = item.parentWorktreeInfo; + const template = parent.isMain + ? getRepositoryWorkspaceTerminalName() + : getWorktreeWorkspaceTerminalName(); + const vars = workspaceFileVars(item.workspaceFileInfo.name, item.workspaceFileInfo.path, parent); + const name = renderTemplate(template, vars); + return { cwd: path.dirname(item.workspaceFileInfo.path), name }; + } + + // WorktreeItem / GroupHeaderItem (repository) + if ("worktreeInfo" in item && item.worktreeInfo) { + const info = item.worktreeInfo; + const template = info.isMain ? getRepositoryTerminalName() : getWorktreeTerminalName(); + const name = renderTemplate(template, worktreeVars(info)); + return { cwd: info.path, name }; + } + + return undefined; +} + +export function openInTerminal(item: TerminalOpenableItem | undefined): void { + if (!item) return; + + const resolved = resolve(item); + if (!resolved) return; + + if (!fs.existsSync(resolved.cwd)) { + void vscode.window.showErrorMessage( + `Cannot open terminal: directory does not exist — ${resolved.cwd}`, + ); + return; + } + + try { + const terminal = vscode.window.createTerminal({ + name: resolved.name, + cwd: resolved.cwd, + }); + terminal.show(); + } catch (error) { + logError("Failed to create terminal", error); + void vscode.window.showErrorMessage("Failed to create terminal."); + } +} diff --git a/src/commands/openWorkspace.ts b/src/commands/openWorkspace.ts index 5b1afae..21682df 100644 --- a/src/commands/openWorkspace.ts +++ b/src/commands/openWorkspace.ts @@ -2,12 +2,14 @@ import type { OpenBehavior } from "../types.js"; import type { FavoriteItem } from "../views/favoriteItem.js"; import type { WorkspaceFileItem } from "../views/workspaceFileItem.js"; import type { WorktreeItem } from "../views/worktreeItem.js"; +import type { GroupHeaderItem } from "../views/worktreeTreeProvider.js"; import * as vscode from "vscode"; import { getOpenBehavior, updateOpenBehavior } from "../utils/config.js"; +import { openInTerminal } from "./openTerminal.js"; -type OpenableItem = FavoriteItem | WorkspaceFileItem | WorktreeItem; +type OpenableItem = FavoriteItem | GroupHeaderItem | WorkspaceFileItem | WorktreeItem; function openFolder(uri: vscode.Uri, forceNewWindow: boolean): Thenable { return vscode.commands.executeCommand("vscode.openFolder", uri, { @@ -15,13 +17,15 @@ function openFolder(uri: vscode.Uri, forceNewWindow: boolean): Thenable }); } -async function askAndOpen(uri: vscode.Uri): Promise { +async function askAndOpen(item: OpenableItem, uri: vscode.Uri): Promise { const pick = await vscode.window.showQuickPick( [ { label: "Open in New Window", behavior: "newWindow" as const, persist: false }, { label: "Open in Current Window", behavior: "currentWindow" as const, persist: false }, + { label: "Open in Terminal", behavior: "terminal" as const, persist: false }, { label: "Always Open in New Window", behavior: "newWindow" as const, persist: true }, { label: "Always Open in Current Window", behavior: "currentWindow" as const, persist: true }, + { label: "Always Open in Terminal", behavior: "terminal" as const, persist: true }, ], { placeHolder: "How would you like to open this workspace?" }, ); @@ -32,14 +36,27 @@ async function askAndOpen(uri: vscode.Uri): Promise { await updateOpenBehavior(pick.behavior); } - await openFolder(uri, pick.behavior === "newWindow"); + await openWithBehavior(item, uri, pick.behavior); } -async function openWithBehavior(uri: vscode.Uri, behavior: OpenBehavior): Promise { - if (behavior === "ask") { - await askAndOpen(uri); - } else { - await openFolder(uri, behavior === "newWindow"); +async function openWithBehavior(item: OpenableItem, uri: vscode.Uri, behavior: OpenBehavior): Promise { + switch (behavior) { + case "ask": + await askAndOpen(item, uri); + break; + case "terminal": + openInTerminal(item); + break; + case "newWindow": + await openFolder(uri, true); + break; + case "currentWindow": + await openFolder(uri, false); + break; + default: { + const _: never = behavior; + throw new Error(`Unhandled openBehavior: ${behavior}`); + } } } @@ -54,7 +71,7 @@ function resolveUri(item: OpenableItem | undefined): vscode.Uri | undefined { return vscode.Uri.file(item.workspaceFileInfo.path); } - if ("worktreeInfo" in item) { + if ("worktreeInfo" in item && item.worktreeInfo) { return vscode.Uri.file(item.worktreeInfo.path); } @@ -74,7 +91,8 @@ export async function openInCurrentWindow(item: OpenableItem | undefined): Promi } export async function openDefault(item: OpenableItem | undefined): Promise { + if (!item) return; const uri = resolveUri(item); if (!uri) return; - await openWithBehavior(uri, getOpenBehavior()); + await openWithBehavior(item, uri, getOpenBehavior()); } diff --git a/src/constants.ts b/src/constants.ts index 02d05fe..a311068 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -9,6 +9,7 @@ export const CMD_ADD_FAVORITE = "gitWorkGrove.addFavorite"; export const CMD_REMOVE_FAVORITE = "gitWorkGrove.removeFavorite"; export const CMD_MOVE_FAVORITE_UP = "gitWorkGrove.moveFavoriteUp"; export const CMD_MOVE_FAVORITE_DOWN = "gitWorkGrove.moveFavoriteDown"; +export const CMD_OPEN_IN_TERMINAL = "gitWorkGrove.openInTerminal"; export const CMD_COPY_NAME = "gitWorkGrove.copyName"; export const CMD_COPY_PATH = "gitWorkGrove.copyPath"; export const CMD_REVEAL_IN_OS = "gitWorkGrove.revealInOS"; diff --git a/src/extension.ts b/src/extension.ts index 646d0f3..662b09a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -5,6 +5,7 @@ import * as vscode from "vscode"; import { copyName, copyPath } from "./commands/copyInfo.js"; import { moveFavoriteDown, moveFavoriteUp } from "./commands/moveFavorite.js"; +import { openInTerminal } from "./commands/openTerminal.js"; import { openDefault, openInCurrentWindow, openInNewWindow } from "./commands/openWorkspace.js"; import { pruneWorktrees } from "./commands/pruneWorktrees.js"; import { toggleFavorite } from "./commands/toggleFavorite.js"; @@ -17,6 +18,7 @@ import { CMD_MOVE_FAVORITE_UP, CMD_OPEN_IN_CURRENT_WINDOW, CMD_OPEN_IN_NEW_WINDOW, + CMD_OPEN_IN_TERMINAL, CMD_PRUNE_WORKTREES, CMD_REFRESH, CMD_REMOVE_FAVORITE, @@ -84,6 +86,8 @@ export async function activate(context: vscode.ExtensionContext): Promise openInNewWindow(item)), vscode.commands.registerCommand(CMD_OPEN_IN_CURRENT_WINDOW, (item) => openInCurrentWindow(item)), + vscode.commands.registerCommand(CMD_OPEN_IN_TERMINAL, (item) => + openInTerminal(item)), vscode.commands.registerCommand(CMD_ADD_FAVORITE, (item) => toggleFavorite(item, favorites, treeProvider)), vscode.commands.registerCommand(CMD_REMOVE_FAVORITE, (item) => @@ -139,6 +143,14 @@ export async function activate(context: vscode.ExtensionContext): Promise if ("workspaceFileInfo" in item) { if (typeof item.contextValue === "string" && item.contextValue.includes("current")) return; openDefault(item); + return; + } + + // Handle leaf WorktreeItem / GroupHeaderItem (repository) — no workspace files + if ("worktreeInfo" in item && item.worktreeInfo) { + if (item.collapsibleState !== vscode.TreeItemCollapsibleState.None) return; + if (typeof item.contextValue === "string" && item.contextValue.includes("current")) return; + openDefault(item); } }), ); diff --git a/src/types.ts b/src/types.ts index 1c1bedb..3f7ba58 100644 --- a/src/types.ts +++ b/src/types.ts @@ -23,4 +23,4 @@ export interface ResolvedFavorite { parentWorktreeInfo?: WorktreeInfo; } -export type OpenBehavior = "ask" | "currentWindow" | "newWindow"; +export type OpenBehavior = "ask" | "currentWindow" | "newWindow" | "terminal"; diff --git a/src/utils/resolveItemPath.ts b/src/utils/resolveItemPath.ts index c87fddc..3bb65c2 100644 --- a/src/utils/resolveItemPath.ts +++ b/src/utils/resolveItemPath.ts @@ -7,6 +7,6 @@ export type ActionableItem = FavoriteItem | WorkspaceFileItem | WorktreeItem; export function resolveItemPath(item: ActionableItem): string | undefined { if ("favoritePath" in item) return item.favoritePath; if ("workspaceFileInfo" in item) return item.workspaceFileInfo.path; - if ("worktreeInfo" in item) return item.worktreeInfo.path; + if ("worktreeInfo" in item && item.worktreeInfo) return item.worktreeInfo.path; return undefined; } diff --git a/src/utils/template.ts b/src/utils/template.ts index debd676..8a06464 100644 --- a/src/utils/template.ts +++ b/src/utils/template.ts @@ -124,3 +124,18 @@ export function getFavoriteWorktreeWorkspaceLabel(): string { export function getFavoriteWorktreeWorkspaceDescription(): string { return get("git-work-grove.template.favoriteWorktreeWorkspace.description", "🌲 {worktree}"); } + +// --- Terminal name getters (4 templates — favorites reuse non-favorite) --- + +export function getRepositoryTerminalName(): string { + return get("git-work-grove.template.repository.terminalName", "{ref}"); +} +export function getWorktreeTerminalName(): string { + return get("git-work-grove.template.worktree.terminalName", "{ref}"); +} +export function getRepositoryWorkspaceTerminalName(): string { + return get("git-work-grove.template.repositoryWorkspace.terminalName", "{name}"); +} +export function getWorktreeWorkspaceTerminalName(): string { + return get("git-work-grove.template.worktreeWorkspace.terminalName", "{name} ({ref})"); +} diff --git a/src/views/favoriteItem.ts b/src/views/favoriteItem.ts index 43491a5..80ae287 100644 --- a/src/views/favoriteItem.ts +++ b/src/views/favoriteItem.ts @@ -1,4 +1,4 @@ -import type { ResolvedFavorite } from "../types.js"; +import type { ResolvedFavorite, WorktreeInfo } from "../types.js"; import * as vscode from "vscode"; @@ -100,11 +100,17 @@ function buildFavoriteTooltip(resolved: ResolvedFavorite): vscode.MarkdownString export class FavoriteItem extends vscode.TreeItem { readonly favoritePath: string; readonly favoriteIndex: number; + readonly favoriteType: "repo" | "worktree" | "workspaceFile"; + readonly worktreeInfo?: WorktreeInfo; + readonly parentWorktreeInfo?: WorktreeInfo; constructor(resolved: ResolvedFavorite, index: number) { super(buildLabel(resolved), vscode.TreeItemCollapsibleState.None); this.favoritePath = resolved.path; this.favoriteIndex = index; + this.favoriteType = resolved.type; + this.worktreeInfo = resolved.worktreeInfo; + this.parentWorktreeInfo = resolved.parentWorktreeInfo; this.iconPath = getIcon(resolved); this.contextValue = buildContextValue(resolved); this.tooltip = buildFavoriteTooltip(resolved); diff --git a/src/views/workspaceFileItem.ts b/src/views/workspaceFileItem.ts index 63c1906..0818be9 100644 --- a/src/views/workspaceFileItem.ts +++ b/src/views/workspaceFileItem.ts @@ -15,6 +15,7 @@ import { buildResourceUri } from "./currentDecorationProvider.js"; export class WorkspaceFileItem extends vscode.TreeItem { readonly workspaceFileInfo: WorkspaceFileInfo; + readonly parentWorktreeInfo: WorktreeInfo; constructor(info: WorkspaceFileInfo, isCurrent: boolean, isFavorite: boolean, parent: WorktreeInfo) { const vars = workspaceFileVars(info.name, info.path, parent); @@ -22,6 +23,7 @@ export class WorkspaceFileItem extends vscode.TreeItem { const descTemplate = parent.isMain ? getRepositoryWorkspaceDescription() : getWorktreeWorkspaceDescription(); super(renderTemplate(labelTemplate, vars), vscode.TreeItemCollapsibleState.None); this.workspaceFileInfo = info; + this.parentWorktreeInfo = parent; const parts = ["workspaceFile"]; if (isCurrent) parts.push("current");