From 98576310a411ccf192a6f71ba2703d69ba6ddfd4 Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 27 Jul 2026 01:07:41 +0900 Subject: [PATCH 1/3] Overhaul sidebar context menus and PR click-through Worktree rows gain a regrouped context menu: New Terminal Tab (opens at the worktree root via the new RepositoriesFeature.newTerminalTab action, never inheriting the focused surface cwd), conditional Stop Running Script, Copy Branch Name, Open Pull Request, and Close All Tabs in a teardown group next to Archive/Delete (disabled at zero tabs). Main worktree rows now share the menu minus pin/archive/delete. Repository headers get New Worktree in the context menu (git repos) or Copy Path / Reveal in Finder (plain folders and workspaces), with the hover ellipsis menu sharing the same builder; workspace child rows get Copy Path / Reveal in Finder. The PR #N sidebar tag is now a link to the pull request URL. Close-all confirmations name the target worktree via a new pure TerminalCloseConfirmationPolicy.informativeMessage helper, since the prompt can now be triggered from the sidebar against tabs that are not visible. Claude-Session: https://claude.ai/code/session_016xMf38MYbMoRwGMHzBASyu --- docs-ai/052-sidebar-context-menus/000-plan.md | 153 ++++++++++++++++++ .../052-sidebar-context-menus/001-action.md | 69 ++++++++ docs-ai/README.md | 1 + docs/components/github-pull-requests.md | 4 +- docs/components/repositories-and-worktrees.md | 18 ++- docs/components/workspaces.md | 1 + .../Models/SidebarPresentation.swift | 1 + .../RepositoriesFeature+CoreReducer.swift | 14 ++ .../RepositoriesFeature+StateQueries.swift | 3 +- .../Reducer/RepositoriesFeature.swift | 1 + .../Views/RepositorySectionView.swift | 70 +++++--- .../Views/WorkspaceChildRowsView.swift | 10 ++ .../Repositories/Views/WorktreeRow.swift | 5 + .../Repositories/Views/WorktreeRowsView.swift | 50 ++++++ .../TerminalCloseConfirmationPolicy.swift | 20 +++ .../WorktreeTerminalState+Surfaces.swift | 11 +- supacodeTests/RepositoriesFeatureTests.swift | 80 +++++++++ ...TerminalCloseConfirmationPolicyTests.swift | 49 ++++++ 18 files changed, 528 insertions(+), 32 deletions(-) create mode 100644 docs-ai/052-sidebar-context-menus/000-plan.md create mode 100644 docs-ai/052-sidebar-context-menus/001-action.md diff --git a/docs-ai/052-sidebar-context-menus/000-plan.md b/docs-ai/052-sidebar-context-menus/000-plan.md new file mode 100644 index 00000000..e0934cbd --- /dev/null +++ b/docs-ai/052-sidebar-context-menus/000-plan.md @@ -0,0 +1,153 @@ +# 052 — Sidebar Context Menu Overhaul: Plan + +| | | +| --- | --- | +| **Status** | Implemented | +| **Anchor date** | 2026-07-27 | +| **Primary PRs** | #613 | +| **Related** | [050-sidebar-expand-active-and-worktree-tab-badges](../050-sidebar-expand-active-and-worktree-tab-badges/000-plan.md), `docs/components/repositories-and-worktrees.md` | + +## Background + +The sidebar is the primary entry point for selecting repositories, worktrees, and agent +run targets, but its context menus lag behind what the rows can already do: + +- Worktree rows have no terminal actions (new tab, close all tabs, stop run script) even + though hover affordances for run-script stop already exist, and the terminal tab bar + already offers "Close All". +- Repository headers expose `New Worktree` only as a hover-`+` button, not in the context + menu; plain-folder and workspace headers have no path actions even though their + `rootURL` *is* the directory the user works in. +- Workspace child rows have no context menu at all. +- The PR badge on a row ("PR #2562 · Mergeable") is display-only; there is no way to open + the pull request from the sidebar, even though `GithubPullRequest.url` is already + fetched. +- `WorktreeTerminalState.closeAllTabs()`'s confirmation alert says only "Close Terminal + Tabs?" with no worktree identity — acceptable from the tab bar, unacceptable from the + sidebar where the target worktree's tabs may not be visible. + +A design review (this entry's planning discussion) settled the object boundary: **path +and terminal actions follow the "runnable directory" capability, not the node level**. +Git repo headers are logical groups (their `rootURL` can be a `.bare` dir) and get no +path actions; worktree rows, plain-folder headers, and workspace headers are directory +objects and do. + +## Goals + +- Restructure the worktree row context menu into the approved grouped spec (below), for + both main and non-main worktrees. +- `New Terminal Tab` selects the worktree, then creates a tab cwd'd at the **worktree + root** (not inheriting the focused surface's cwd, unlike Ghostty's ⌘T), and focuses it. +- `Close All Tabs` reuses `closeAllTabs()` protections; disabled (not hidden) at 0 tabs; + the confirmation alert names the worktree. +- Repo header menu gains `New Worktree` (git repos); plain-folder and workspace headers + gain `Copy Path` / `Reveal in Finder`; `Repo Settings` label becomes + `Repository Settings…`. +- Workspace child rows gain `Copy Path` / `Reveal in Finder`. +- The `PR #N` segment in the row info line becomes a click-through link to the PR URL, + and worktree rows gain an `Open Pull Request` menu item when PR info exists. + +### Non-goals + +- Bulk close across selected worktrees or a whole repository (needs an aggregated + worktree-naming confirmation model first; deferred). +- `Run Custom Command >` submenu (future growth direction). +- A "background tab" variant of New Terminal Tab that does not switch selection. +- `New Terminal Tab` on workspace child rows (their one-bound-tab-per-directory model + makes a second tab's semantics undefined). + +## Menu spec + +Worktree row (non-main; main drops Pin/Archive/Delete): + +```text +New Terminal Tab +Stop Running Script ← only while a Prowl run script is tracked +──────────────── +Pin to Top / Unpin +──────────────── +Copy Path +Copy Branch Name +Reveal in Finder +Open Pull Request ← only when info.pullRequest exists +──────────────── +Show Diff +Show Outgoing Changes +──────────────── +Close All Tabs ← disabled at 0 tabs; not styled destructive +Archive Worktree ← bulk: "Archive Selected Worktrees" +Delete Worktree (⇧⌘⌫) ← bulk: "Delete Selected Worktrees"; .destructive +``` + +`Close All Tabs` sits in the bottom teardown group (escalation ladder: close tabs → +archive → delete) rather than next to `New Terminal Tab`: context menus open with the +first item under the pointer, and a bulk agent-killing action must not live there. +Single-target vs bulk rule: only actions whose titles say "Selected" act on the +multi-selection; everything else acts on the clicked row (existing Copy Path precedent). + +Git repo header: `New Worktree` / ─ / `Repository Settings…` / ─ / `Remove Repository`. +Plain-folder and workspace headers: `Copy Path`, `Reveal in Finder` / ─ / +`Repository Settings…` / ─ / `Remove Repository` (unqualified labels — the menu acts on +the clicked node). Workspace child rows: `Copy Path`, `Reveal in Finder`. + +## Design / Approach + +- `supacode/Features/Repositories/Views/WorktreeRowsView.swift` — rebuild + `rowContextMenu`; extend menu availability to main-worktree rows (already gated by + `isRemovable`, which covers transient lifecycle states). New Terminal Tab flows through + the reducer so selection + tab creation stay ordered: a new `RepositoriesFeature` + action selects the worktree and effects + `terminalClient.send(.createTabInDirectory(worktree, directory: worktree.workingDirectory))` + (command already exists). Close All Tabs calls + `terminalManager.stateIfExists(...)?.closeAllTabs()` directly, like existing hover + actions. +- `supacode/Features/Repositories/Views/RepositorySectionView.swift` — branch the header + context menu by `repository.capabilities.supportsWorktrees` / `isWorkspace` / plain; + reuse `createRandomWorktreeInRepository` for New Worktree; rename settings labels in + both the `…` hover menu and the context menu. +- `supacode/Features/Repositories/Models/SidebarPresentation.swift` — add + `workingDirectory: URL` to `WorkspaceChildRowModel` (source: + `ResolvedWorkspaceChild.workingDirectory`); + `supacode/Features/Repositories/Views/WorkspaceChildRowsView.swift` gains the menu. +- `supacode/Features/Repositories/Views/WorktreeRow.swift` — pass the PR URL into + `WorktreeRowInfoView` and set `.link` on the "PR #N" `AttributedString` segment (keeps + current secondary color); workspace child rows get the link for free. +- `supacode/Features/Terminal/Models/WorktreeTerminalState.swift` / + `WorktreeTerminalState+Surfaces.swift` — extend + `TerminalCloseConfirmationTarget.tabs(count:)` with the worktree name so + `closeAllTabs()`'s alert reads "Close All Tabs in “name”?" from every trigger surface. +- Tests: reducer test for the new-tab action (selection + command emission); + copy tests for the extended confirmation target. Docs: update + `docs/components/repositories-and-worktrees.md` (+ `docs/components/terminal.md` if it + describes the close-all alert). + +## Alternatives & decisions + +- **Terminal submenu vs top-level items**: top-level. Two-to-three items behind a submenu + hurts discoverability; ~11 items in 5 groups matches Finder-level density. The only + future submenu candidate is a dynamic `Run Custom Command >` list. +- **`Close All Tabs` placement**: proposal had it at the top paired with New Terminal + Tab; review moved it to the teardown group (slip-distance from the pointer, frequency, + escalation-ladder semantics). Kept non-destructive styling to match the tab bar. +- **Hide vs disable at 0 tabs**: disable, per HIG — stable menu shape teaches the + capability. `Stop Running Script` stays conditional (transient verb, meaningless when + idle — Finder's Eject precedent). +- **Label `Close All Tabs` vs `Close All Tabs in This Worktree`**: short form; every item + in this menu acts on the clicked worktree, and `Archive/Delete Worktree` nearby + disambiguates tabs-vs-worktree. The long form exists only for the canvas tab menu + where cross-worktree ambiguity is real. +- **New Terminal Tab semantics**: selects + focuses (macOS "Open in New Tab" precedent) + and pins cwd to the worktree root — Ghostty's default new-tab cwd inheritance is wrong + in a worktree-jumping context. A non-selecting background variant was rejected for the + default verb. +- **Git repo header path actions**: rejected — `Repository.rootURL` may be `.bare` + (`Repository.name(for:)` special-cases it), so the header is not a user-enterable + directory. Plain folders and workspaces keep path actions because their root is the + working directory. +- **PR opening**: `.link` on the existing attributed segment beats a separate button + (no layout change, whole-row tap still selects) — with the context-menu item as the + reliable/discoverable fallback. + +## Amendments + +(append `- Updated 2026-MM-DD: ... — see [00N-topic.md](00N-topic.md)` lines here) diff --git a/docs-ai/052-sidebar-context-menus/001-action.md b/docs-ai/052-sidebar-context-menus/001-action.md new file mode 100644 index 00000000..821f7412 --- /dev/null +++ b/docs-ai/052-sidebar-context-menus/001-action.md @@ -0,0 +1,69 @@ +# 052 — Sidebar Context Menu Overhaul: Action Log + +## Timeline + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-07-27 | Design review of the proposed menus; object boundary settled (path/terminal actions follow the runnable-directory capability, not the node level); `Close All Tabs` moved to the teardown group | this entry's plan | +| 2026-07-27 | Implemented worktree-row menu regroup + `newTerminalTab` reducer action, header menus, workspace-child menu, PR click-through, worktree-named close confirmation; tests + docs | PR #613 | + +## Outcome & current state (as of 2026-07-27) + +- `supacode/Features/Repositories/Views/WorktreeRowsView.swift` — worktree row menu is + now: New Terminal Tab · Stop Running Script (conditional) / Pin (non-main) / Copy Path · + Copy Branch Name · Reveal in Finder · Open Pull Request (conditional) / Show Diff · + Show Outgoing Changes / Close All Tabs (disabled at 0 tabs) · Archive · Delete. + Main-worktree rows carry the same menu minus Pin/Archive/Delete (they already passed the + `isRemovable` gate). `newTerminalTab(for:)` falls back to the normal open-worktree flow + when the worktree has no tabs (see Deviations). +- `RepositoriesFeature.Action.newTerminalTab(Worktree.ID)` + (`supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift`) — + selects the worktree (or canvas-focuses it when Canvas is showing) and sends + `TerminalClient.Command.createTabInDirectory(worktree, directory: worktree.workingDirectory)` + so the tab opens at the worktree root instead of inheriting the focused surface's cwd. +- `supacode/Features/Repositories/Views/RepositorySectionView.swift` — hover `…` menu and + header context menu share one `headerMenuItems` builder: git repos get New Worktree / + Repo Settings… / Remove Repository; plain folders and workspaces get Copy Path / Reveal + in Finder instead of New Worktree. +- `supacode/Features/Repositories/Views/WorkspaceChildRowsView.swift` + + `WorkspaceChildRowModel.workingDirectory` + (`supacode/Features/Repositories/Models/SidebarPresentation.swift`) — child rows now + have Copy Path / Reveal in Finder. +- `supacode/Features/Repositories/Views/WorktreeRow.swift` — the `PR #N` info segment is + an `AttributedString` link to `GithubPullRequest.url` (workspace child rows inherit it); + the row menu's Open Pull Request reuses + `.githubIntegration(.pullRequestAction(_, .openOnCodeHost))` with its repo-URL fallback. +- `TerminalCloseConfirmationPolicy.informativeMessage(for:worktreeName:)` + (`supacode/Features/Terminal/Models/TerminalCloseConfirmationPolicy.swift`) — pure + message builder; `WorktreeTerminalState` alert bodies now read "… in “name” …" so + sidebar-triggered closes always identify the target worktree. +- Tests: `supacodeTests/RepositoriesFeatureTests.swift` (3 `newTerminalTab` cases: + normal, canvas, unknown-worktree) and + `supacodeTests/TerminalCloseConfirmationPolicyTests.swift` (2 message cases). Docs: + `docs/components/repositories-and-worktrees.md`, `workspaces.md`, + `github-pull-requests.md`. + +Verified: `make build-app` clean, targeted tests pass (9/9), `make check` clean; debug app +launched via self-verify (CLI list/open OK, no relevant log errors). + +## Deviations from plan + +- Menu label shipped as **Repo Settings…** instead of the planned "Repository Settings…": + "Repo Settings" is the established term in the Command Palette and Shelf spine, so only + the HIG ellipsis was added. +- `New Terminal Tab` on a worktree with zero tabs routes through the ordinary + open-worktree flow (select + `ensureInitialTab`) instead of `.newTerminalTab`: the + ensure-initial path already creates the first tab at the root and runs the repo setup + script, and always sending `.createTabInDirectory` would race it into a duplicate tab. + The reducer action is only used when tabs exist, where the cwd-inheritance problem + actually occurs. + +## Open questions + +- The PR-link segment's styling (secondary color retained despite `link`) and the menu + interactions were not visually verified end-to-end: the sidebar section with a PR badge + was collapsed in the debug instance and the agent host lacks Accessibility permission + for AX-driven clicks. Needs a quick manual right-click pass. +- `.claude/skills/self-verify-prowl/scripts/helpers.sh` `debug_pids` greps + `Debug/Prowl.app/...` but the debug bundle is `Prowl Debug.app`, so `debug_pids` / + `debug_window_id` return nothing; worked around by passing the PID explicitly. diff --git a/docs-ai/README.md b/docs-ai/README.md index b5cceedd..38b5cefe 100644 --- a/docs-ai/README.md +++ b/docs-ai/README.md @@ -103,3 +103,4 @@ agent-facing manual for that). | 049 | [agents-toolbar-entry](049-agents-toolbar-entry/000-plan.md) | 2026-07-20 | Agents status capsule + staged handoff HUD toolbar entry | | 050 | [sidebar-expand-active-and-worktree-tab-badges](050-sidebar-expand-active-and-worktree-tab-badges/000-plan.md) | 2026-07-25 | Sidebar Expand Active third state + per-worktree tab count badges | | 051 | [repository-icon-detection](051-repository-icon-detection/000-plan.md) | 2026-07-25 | High-confidence local repository icon detection on add; deferred, reviewable Foundation Model recommendations | +| 052 | [sidebar-context-menus](052-sidebar-context-menus/000-plan.md) | 2026-07-27 | Sidebar context menu overhaul: worktree terminal actions, header/workspace path actions, PR click-through | diff --git a/docs/components/github-pull-requests.md b/docs/components/github-pull-requests.md index 0aab1b9a..fdf0759f 100644 --- a/docs/components/github-pull-requests.md +++ b/docs/components/github-pull-requests.md @@ -41,7 +41,9 @@ GitHub remote, stale PR badges are cleared. time remaining. PR status can surface as a badge on the worktree and as a summary in the command -palette. +palette. The **PR #N** tag in a sidebar row is a link — click it to open the +pull request in the browser; right-click the row → **Open Pull Request** does +the same (falling back to the repository page when no PR URL is known). ## Actions (via Command Palette, when a PR exists) diff --git a/docs/components/repositories-and-worktrees.md b/docs/components/repositories-and-worktrees.md index 5150b8e9..dbfcceaf 100644 --- a/docs/components/repositories-and-worktrees.md +++ b/docs/components/repositories-and-worktrees.md @@ -51,6 +51,7 @@ and stores its actual git root so branches and worktrees continue to load. - **Shortcut:** `⌘N` (`new_worktree`) - **Button:** the **+** on a repository's header (only for git repos that support worktrees). +- **Context menu:** right-click a repository header → "New Worktree". - **Command Palette:** "New Worktree". By default a **creation prompt** appears (controlled by @@ -184,8 +185,21 @@ CLion, PhpStorm, RubyMine), GitHub Desktop and terminals (Alacritty, Ghostty, iTerm2, Kitty, Warp, WezTerm). If the chosen app isn't installed, Prowl shows an alert. -Other per-row context-menu items: **Copy Path**, **Reveal in Finder**. (Repo -Settings lives on the repository **header** menu, not the worktree row.) +Other per-row context-menu items: **New Terminal Tab** (selects the worktree and +opens a tab at the worktree **root** — unlike a plain new tab, it never inherits +the focused tab's current directory), **Stop Running Script** (only while a +Prowl-tracked run script is running), **Copy Path**, **Copy Branch Name**, +**Reveal in Finder**, **Open Pull Request** (only when the worktree has a PR), +and **Close All Tabs** (disabled when the worktree has no tabs; uses the same +active-agent / long-running-command confirmation as the tab bar's "Close All", +and the confirmation names the target worktree). + +The repository **header** menu (right-click, or the **⋯** button) offers +**New Worktree** (git repos only), **Repo Settings…**, and +**Remove Repository**. Plain folders and workspaces get **Copy Path** / +**Reveal in Finder** instead of New Worktree; git repo headers deliberately have +no path actions because a repository's root can be a bare directory — use the +worktree rows for paths. ## Repository appearance (icon & color) diff --git a/docs/components/workspaces.md b/docs/components/workspaces.md index 850fdb8a..697a3de3 100644 --- a/docs/components/workspaces.md +++ b/docs/components/workspaces.md @@ -110,6 +110,7 @@ current branch, uncommitted line counts, and pull request badge when available, including immediately after a newly created workspace is opened. Click a child row to select it and focus its terminal tab rooted at that repository folder inside the workspace, creating that tab the first time it is selected. +Right-click a child row for **Copy Path** / **Reveal in Finder**. ## Removing a workspace diff --git a/supacode/Features/Repositories/Models/SidebarPresentation.swift b/supacode/Features/Repositories/Models/SidebarPresentation.swift index 403a24f3..466c517f 100644 --- a/supacode/Features/Repositories/Models/SidebarPresentation.swift +++ b/supacode/Features/Repositories/Models/SidebarPresentation.swift @@ -92,6 +92,7 @@ struct WorkspaceChildRowModel: Equatable, Identifiable { let repositoryName: String let branchName: String? let info: WorktreeInfoEntry? + let workingDirectory: URL } struct FailedRepositoryModel: Equatable, Identifiable { diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift index f999b4e5..e6186c4d 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift @@ -627,6 +627,20 @@ extension RepositoriesFeature { let selectedWorktree = state.worktree(for: worktreeID) return .send(.delegate(.selectedWorktreeChanged(selectedWorktree))) + case .newTerminalTab(let worktreeID): + guard let worktree = state.worktree(for: worktreeID) else { return .none } + // Pin the new tab's cwd to the worktree root: Ghostty's default new-tab + // behavior inherits the focused surface's cwd, which is wrong when the + // user targets a worktree from the sidebar. + let createTab: Effect = .run { _ in + await terminalClient.send( + .createTabInDirectory(worktree, directory: worktree.workingDirectory)) + } + if state.isShowingCanvas { + return .merge(.send(.focusCanvasWorktree(worktreeID)), createTab) + } + return .merge(.send(.selectWorktree(worktreeID, focusTerminal: true)), createTab) + case .focusCanvasRepository(let repositoryID): guard state.isShowingCanvas, let worktree = state.canvasNavigationWorktree(forRepositoryID: repositoryID) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+StateQueries.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+StateQueries.swift index 26a11188..1e974ef1 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+StateQueries.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+StateQueries.swift @@ -210,7 +210,8 @@ extension RepositoriesFeature.State { id: child.id, repositoryName: child.repositoryName, branchName: workspaceChildBranchByID[child.id] ?? child.metadataBranch, - info: workspaceChildInfoByID[child.id] + info: workspaceChildInfoByID[child.id], + workingDirectory: child.workingDirectory ) } } diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 932c0535..685aba9d 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -451,6 +451,7 @@ struct RepositoriesFeature { case selectRepository(Repository.ID?) case openWorkspaceChild(String) case selectWorktree(Worktree.ID?, focusTerminal: Bool = false, recordHistory: Bool = true) + case newTerminalTab(Worktree.ID) case focusCanvasRepository(Repository.ID) case focusCanvasWorktree(Worktree.ID) case selectNextWorktree diff --git a/supacode/Features/Repositories/Views/RepositorySectionView.swift b/supacode/Features/Repositories/Views/RepositorySectionView.swift index 0d3bb2e2..931e110a 100644 --- a/supacode/Features/Repositories/Views/RepositorySectionView.swift +++ b/supacode/Features/Repositories/Views/RepositorySectionView.swift @@ -1,3 +1,4 @@ +import AppKit import ComposableArchitecture import Sharing import SwiftUI @@ -99,15 +100,10 @@ struct RepositorySectionView: View { } if isHovering { Menu { - Button("Repo Settings") { - openRepoSettings() - } - .help("Repo Settings ") - Button("Remove Repository") { - store.send(.repositoryManagement(.requestRemoveRepository(repository.id))) - } - .help("Remove repository ") - .disabled(isRemovingRepository) + headerMenuItems( + isRemovingRepository: isRemovingRepository, + openRepoSettings: openRepoSettings + ) } label: { Label("Repository options", systemImage: "ellipsis") .labelStyle(.iconOnly) @@ -230,15 +226,10 @@ struct RepositorySectionView: View { .accessibilityAddTraits(.isButton) .contentShape(.rect) .contextMenu { - Button("Repo Settings") { - openRepoSettings() - } - .help("Repo Settings ") - Button("Remove Repository") { - store.send(.repositoryManagement(.requestRemoveRepository(repository.id))) - } - .help("Remove repository ") - .disabled(isRemovingRepository) + headerMenuItems( + isRemovingRepository: isRemovingRepository, + openRepoSettings: openRepoSettings + ) } .contentShape(.dragPreview, .rect) .listRowBackground(Color.clear) @@ -273,6 +264,49 @@ struct RepositorySectionView: View { .id(SidebarScrollID.repository(repository.id)) } + /// Shared between the hover `…` menu and the header context menu so both + /// surfaces always offer the same actions. Git repo headers are logical + /// groups (their `rootURL` can be a bare dir), so only worktree-less nodes + /// — plain folders and workspaces — get path actions here. + @ViewBuilder + private func headerMenuItems( + isRemovingRepository: Bool, + openRepoSettings: @escaping () -> Void + ) -> some View { + if repository.capabilities.supportsWorktrees { + Button("New Worktree") { + store.send(.worktreeCreation(.createRandomWorktreeInRepository(repository.id))) + } + .help( + AppShortcuts.helpText( + title: "New Worktree", + commandID: AppShortcuts.CommandID.newWorktree, + in: resolvedKeybindings + ) + ) + .disabled(isRemovingRepository) + } else { + Button("Copy Path") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(repository.rootURL.path, forType: .string) + } + Button("Reveal in Finder") { + NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: repository.rootURL.path) + } + } + Divider() + Button("Repo Settings…") { + openRepoSettings() + } + .help("Repo Settings") + Divider() + Button("Remove Repository") { + store.send(.repositoryManagement(.requestRemoveRepository(repository.id))) + } + .help("Remove repository") + .disabled(isRemovingRepository) + } + private var headerCellHeight: CGFloat { 26 } diff --git a/supacode/Features/Repositories/Views/WorkspaceChildRowsView.swift b/supacode/Features/Repositories/Views/WorkspaceChildRowsView.swift index f815bb75..0ab1aacb 100644 --- a/supacode/Features/Repositories/Views/WorkspaceChildRowsView.swift +++ b/supacode/Features/Repositories/Views/WorkspaceChildRowsView.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI /// Rows for the child repositories of an expanded workspace. @@ -52,6 +53,15 @@ struct WorkspaceChildRowsView: View { } .accessibilityAddTraits(.isButton) .help("Focus Terminal in \(row.repositoryName)") + .contextMenu { + Button("Copy Path") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(row.workingDirectory.path, forType: .string) + } + Button("Reveal in Finder") { + NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: row.workingDirectory.path) + } + } .id(row.id) } } diff --git a/supacode/Features/Repositories/Views/WorktreeRow.swift b/supacode/Features/Repositories/Views/WorktreeRow.swift index bfdf6608..b58925d0 100644 --- a/supacode/Features/Repositories/Views/WorktreeRow.swift +++ b/supacode/Features/Repositories/Views/WorktreeRow.swift @@ -186,6 +186,7 @@ struct WorktreeRow: View { worktreeName: detailText, showsPullRequestTag: showsPullRequestTag, pullRequestNumber: display.pullRequest?.number, + pullRequestURL: display.pullRequest.flatMap { URL(string: $0.url) }, pullRequestState: display.pullRequestState, mergeReadiness: mergeReadiness, isQueued: isQueued, @@ -237,6 +238,7 @@ private struct WorktreeRowInfoView: View { let worktreeName: String let showsPullRequestTag: Bool let pullRequestNumber: Int? + let pullRequestURL: URL? let pullRequestState: String? let mergeReadiness: PullRequestMergeReadiness? let isQueued: Bool @@ -279,6 +281,9 @@ private struct WorktreeRowInfoView: View { appendSeparator() var segment = AttributedString("PR #\(pullRequestNumber)") segment.foregroundColor = .secondary + // Clickable via `Text`'s built-in link handling; the rest of the row + // keeps its tap-to-select behavior. + segment.link = pullRequestURL result.append(segment) } if pullRequestState == "MERGED" { diff --git a/supacode/Features/Repositories/Views/WorktreeRowsView.swift b/supacode/Features/Repositories/Views/WorktreeRowsView.swift index dc015e96..a57dd1a8 100644 --- a/supacode/Features/Repositories/Views/WorktreeRowsView.swift +++ b/supacode/Features/Repositories/Views/WorktreeRowsView.swift @@ -292,6 +292,27 @@ struct WorktreeRowsView: View { } } + private func newTerminalTab(for row: WorktreeRowModel) { + let tabCount = terminalManager.stateIfExists(for: row.id)?.tabManager.tabs.count ?? 0 + guard tabCount > 0 else { + // Opening a worktree that has no tabs already creates its first tab at + // the worktree root (running the repo setup script); reuse that flow so + // the context menu doesn't race `ensureInitialTab` into a second tab. + openWorktree(row.id) + return + } + store.send(.newTerminalTab(row.id)) + } + + private func openWorktree(_ worktreeID: Worktree.ID) { + if store.state.isShowingCanvas { + store.send(.focusCanvasWorktree(worktreeID)) + } else { + store.send(.selectWorktree(worktreeID, focusTerminal: true)) + focusTerminalAfterSelection(worktreeID: worktreeID) + } + } + private func focusTerminalAfterSelection(worktreeID: Worktree.ID) { Task { @MainActor [terminalManager] in for _ in 0..<4 { @@ -445,7 +466,19 @@ struct WorktreeRowsView: View { isBulkSelection ? "Delete Selected Worktrees (\(deleteShortcut))" : "Delete Worktree (\(deleteShortcut))" + let tabCount = terminalManager.stateIfExists(for: row.id)?.tabManager.tabs.count ?? 0 + Button("New Terminal Tab") { + newTerminalTab(for: row) + } + .help("Open a new terminal tab at this worktree's root") + if let stopRunScript = stopRunScriptHandler(for: row.id) { + Button("Stop Running Script") { + stopRunScript() + } + .help("Stop the run script Prowl is tracking for this worktree") + } if !row.isMainWorktree { + Divider() if row.isPinned { Button("Unpin") { togglePin(for: worktree.id, isPinned: true) @@ -458,13 +491,24 @@ struct WorktreeRowsView: View { .help("Pin to top") } } + Divider() Button("Copy Path") { NSPasteboard.general.clearContents() NSPasteboard.general.setString(worktree.workingDirectory.path, forType: .string) } + Button("Copy Branch Name") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(row.name, forType: .string) + } Button("Reveal in Finder") { NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: worktree.workingDirectory.path) } + if row.info?.pullRequest != nil { + Button("Open Pull Request") { + store.send(.githubIntegration(.pullRequestAction(row.id, .openOnCodeHost))) + } + .help("Open this worktree's pull request on the code host") + } Divider() Button("Show Diff") { store.send(.delegate(.showDiff(worktree.id))) @@ -474,6 +518,12 @@ struct WorktreeRowsView: View { store.send(.delegate(.showOutgoingChanges(worktree.id))) } .help("Show committed changes relative to this worktree's base") + Divider() + Button("Close All Tabs") { + terminalManager.stateIfExists(for: row.id)?.closeAllTabs() + } + .help("Close all terminal tabs in this worktree; the worktree itself stays") + .disabled(tabCount == 0) if !row.isMainWorktree || isBulkSelection { Button(archiveTitle) { archiveWorktrees(archiveTargets) diff --git a/supacode/Features/Terminal/Models/TerminalCloseConfirmationPolicy.swift b/supacode/Features/Terminal/Models/TerminalCloseConfirmationPolicy.swift index 46c2318a..0fe493fc 100644 --- a/supacode/Features/Terminal/Models/TerminalCloseConfirmationPolicy.swift +++ b/supacode/Features/Terminal/Models/TerminalCloseConfirmationPolicy.swift @@ -42,6 +42,26 @@ enum TerminalCloseConfirmationPolicy { ) } + /// Alert body for a close prompt. Always names the worktree: the prompt can + /// be triggered from the sidebar against a worktree whose tabs are not + /// visible, so the target's identity must be part of the confirmation. + static func informativeMessage( + for decision: TerminalCloseConfirmationDecision, + worktreeName: String + ) -> String { + let paneText = decision.protectedPaneCount == 1 ? "pane" : "panes" + let reasonText: String + if decision.reasons == Set([.agentActive]) { + reasonText = "active agent work or an unseen agent result" + } else if decision.reasons == Set([.longRunningCommand]) { + reasonText = "a command that has been running for at least 10 seconds" + } else { + reasonText = "active agent work, unseen agent results, or long-running commands" + } + return + "This will close \(decision.protectedPaneCount) \(paneText) in “\(worktreeName)” with \(reasonText)." + } + private static func protectionReason( for candidate: TerminalCloseProtectionCandidate, threshold: TimeInterval diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift index 093b47ab..d342edaf 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift @@ -57,16 +57,7 @@ extension WorktreeTerminalState { } func closeConfirmationMessage(for decision: TerminalCloseConfirmationDecision) -> String { - let paneText = decision.protectedPaneCount == 1 ? "pane" : "panes" - let reasonText: String - if decision.reasons == Set([.agentActive]) { - reasonText = "active agent work or an unseen agent result" - } else if decision.reasons == Set([.longRunningCommand]) { - reasonText = "a command that has been running for at least 10 seconds" - } else { - reasonText = "active agent work, unseen agent results, or long-running commands" - } - return "This will close \(decision.protectedPaneCount) \(paneText) with \(reasonText)." + TerminalCloseConfirmationPolicy.informativeMessage(for: decision, worktreeName: worktree.name) } func splitTree( diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 95e8c346..1a82b516 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -2558,6 +2558,86 @@ struct RepositoriesFeatureTests { await store.receive(\.delegate.selectedWorktreeChanged) } + @Test func newTerminalTabSelectsWorktreeAndCreatesRootTab() async { + let worktree = makeWorktree(id: "/tmp/repo/wt", name: "wt") + let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) + let sentCommands = LockIsolated<[TerminalClient.Command]>([]) + let store = TestStore(initialState: makeState(repositories: [repository])) { + RepositoriesFeature() + } withDependencies: { + $0.terminalClient.send = { command in + sentCommands.withValue { $0.append(command) } + } + } + + await store.send(.newTerminalTab(worktree.id)) + await store.receive(\.selectWorktree) { + $0.selection = .worktree(worktree.id) + $0.sidebarSelectedWorktreeIDs = [worktree.id] + $0.openedWorktreeIDs = [worktree.id] + $0.pendingTerminalFocusWorktreeIDs = [worktree.id] + } + await store.receive(\.delegate.selectedWorktreeChanged) + await store.finish() + + #expect( + sentCommands.value == [ + .createTabInDirectory(worktree, directory: worktree.workingDirectory) + ] + ) + } + + @Test func newTerminalTabInCanvasFocusesCanvasWorktreeWithoutLeavingCanvas() async { + let worktree = makeWorktree(id: "/tmp/repo/wt", name: "wt") + let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) + var initialState = makeState(repositories: [repository]) + initialState.selection = .canvas + + let sentCommands = LockIsolated<[TerminalClient.Command]>([]) + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } withDependencies: { + $0.terminalClient.send = { command in + sentCommands.withValue { $0.append(command) } + } + } + + await store.send(.newTerminalTab(worktree.id)) + await store.receive(\.focusCanvasWorktree) { + $0.nextCanvasFocusRequestID = 1 + $0.pendingCanvasFocusRequest = CanvasFocusRequest( + id: 1, + target: .worktree(worktree.id) + ) + $0.openedWorktreeIDs = [worktree.id] + } + await store.finish() + + #expect(store.state.selection == .canvas) + // The two effects run concurrently, so assert membership rather than order. + #expect(sentCommands.value.count == 2) + #expect( + sentCommands.value.contains( + .createTabInDirectory(worktree, directory: worktree.workingDirectory) + ) + ) + #expect( + sentCommands.value.contains( + .ensureInitialTab(worktree, runSetupScriptIfNew: false, focusing: false) + ) + ) + } + + @Test func newTerminalTabWithUnknownWorktreeDoesNothing() async { + let worktree = makeWorktree(id: "/tmp/repo/wt", name: "wt") + let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) + let store = TestStore(initialState: makeState(repositories: [repository])) { + RepositoriesFeature() + } + + await store.send(.newTerminalTab("/tmp/unknown")) + } + @Test func activeAgentEntryTappedFocusesSurfaceBeforeSelectingWorktree() async { let worktree = makeWorktree(id: "/tmp/repo/wt", name: "wt") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) diff --git a/supacodeTests/TerminalCloseConfirmationPolicyTests.swift b/supacodeTests/TerminalCloseConfirmationPolicyTests.swift index b3553fa2..739b78e0 100644 --- a/supacodeTests/TerminalCloseConfirmationPolicyTests.swift +++ b/supacodeTests/TerminalCloseConfirmationPolicyTests.swift @@ -97,4 +97,53 @@ struct TerminalCloseConfirmationPolicyTests { #expect(decision.reasons.contains(.agentActive)) #expect(decision.reasons.contains(.longRunningCommand)) } + + @Test func informativeMessageNamesWorktree() { + let decision = TerminalCloseConfirmationPolicy.decision( + for: [ + TerminalCloseProtectionCandidate( + hasAgent: true, + agentDisplayState: .working, + commandRunningDuration: nil + ) + ] + ) + + let message = TerminalCloseConfirmationPolicy.informativeMessage( + for: decision, + worktreeName: "feature/foo" + ) + + #expect( + message + == "This will close 1 pane in “feature/foo” with active agent work or an unseen agent result." + ) + } + + @Test func informativeMessageAggregatesMixedReasons() { + let decision = TerminalCloseConfirmationPolicy.decision( + for: [ + TerminalCloseProtectionCandidate( + hasAgent: true, + agentDisplayState: .working, + commandRunningDuration: nil + ), + TerminalCloseProtectionCandidate( + hasAgent: false, + agentDisplayState: nil, + commandRunningDuration: 30 + ), + ] + ) + + let message = TerminalCloseConfirmationPolicy.informativeMessage( + for: decision, + worktreeName: "wt" + ) + + #expect( + message + == "This will close 2 panes in “wt” with active agent work, unseen agent results, or long-running commands." + ) + } } -- 2.51.2 From a833cba8653011acc1f9af968fa297a233a04c35 Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 27 Jul 2026 01:08:18 +0900 Subject: [PATCH 2/3] Correct PR number in docs-ai 052 entry Claude-Session: https://claude.ai/code/session_016xMf38MYbMoRwGMHzBASyu --- docs-ai/052-sidebar-context-menus/000-plan.md | 2 +- docs-ai/052-sidebar-context-menus/001-action.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs-ai/052-sidebar-context-menus/000-plan.md b/docs-ai/052-sidebar-context-menus/000-plan.md index e0934cbd..0c07d5d8 100644 --- a/docs-ai/052-sidebar-context-menus/000-plan.md +++ b/docs-ai/052-sidebar-context-menus/000-plan.md @@ -4,7 +4,7 @@ | --- | --- | | **Status** | Implemented | | **Anchor date** | 2026-07-27 | -| **Primary PRs** | #613 | +| **Primary PRs** | #614 | | **Related** | [050-sidebar-expand-active-and-worktree-tab-badges](../050-sidebar-expand-active-and-worktree-tab-badges/000-plan.md), `docs/components/repositories-and-worktrees.md` | ## Background diff --git a/docs-ai/052-sidebar-context-menus/001-action.md b/docs-ai/052-sidebar-context-menus/001-action.md index 821f7412..a920aa75 100644 --- a/docs-ai/052-sidebar-context-menus/001-action.md +++ b/docs-ai/052-sidebar-context-menus/001-action.md @@ -5,7 +5,7 @@ | Date | Change | Ref | | --- | --- | --- | | 2026-07-27 | Design review of the proposed menus; object boundary settled (path/terminal actions follow the runnable-directory capability, not the node level); `Close All Tabs` moved to the teardown group | this entry's plan | -| 2026-07-27 | Implemented worktree-row menu regroup + `newTerminalTab` reducer action, header menus, workspace-child menu, PR click-through, worktree-named close confirmation; tests + docs | PR #613 | +| 2026-07-27 | Implemented worktree-row menu regroup + `newTerminalTab` reducer action, header menus, workspace-child menu, PR click-through, worktree-named close confirmation; tests + docs | PR #614 | ## Outcome & current state (as of 2026-07-27) -- 2.51.2 From 09314eab06891025d7d7c189ca3398ef15435a59 Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 28 Jul 2026 00:18:48 +0900 Subject: [PATCH 3/3] Fix Canvas new tab focus --- docs-ai/052-sidebar-context-menus/000-plan.md | 1 + .../052-sidebar-context-menus/001-action.md | 7 ++--- .../002-canvas-new-tab-focus.md | 25 ++++++++++++++++++ supacode/App/supacodeApp.swift | 3 +++ .../Clients/Terminal/TerminalClient.swift | 4 +++ .../RepositoriesFeature+CoreReducer.swift | 21 ++++++++++++--- .../Reducer/RepositoriesFeature.swift | 1 + .../WorktreeTerminalManager.swift | 9 ++++++- supacodeTests/RepositoriesFeatureTests.swift | 26 ++++++------------- 9 files changed, 72 insertions(+), 25 deletions(-) create mode 100644 docs-ai/052-sidebar-context-menus/002-canvas-new-tab-focus.md diff --git a/docs-ai/052-sidebar-context-menus/000-plan.md b/docs-ai/052-sidebar-context-menus/000-plan.md index 0c07d5d8..43bed600 100644 --- a/docs-ai/052-sidebar-context-menus/000-plan.md +++ b/docs-ai/052-sidebar-context-menus/000-plan.md @@ -150,4 +150,5 @@ the clicked node). Workspace child rows: `Copy Path`, `Reveal in Finder`. ## Amendments +- Updated 2026-07-27: Canvas New Terminal Tab now focuses its exact created tab — see [002-canvas-new-tab-focus.md](002-canvas-new-tab-focus.md). (append `- Updated 2026-MM-DD: ... — see [00N-topic.md](00N-topic.md)` lines here) diff --git a/docs-ai/052-sidebar-context-menus/001-action.md b/docs-ai/052-sidebar-context-menus/001-action.md index a920aa75..7e4d9fa4 100644 --- a/docs-ai/052-sidebar-context-menus/001-action.md +++ b/docs-ai/052-sidebar-context-menus/001-action.md @@ -18,9 +18,10 @@ when the worktree has no tabs (see Deviations). - `RepositoriesFeature.Action.newTerminalTab(Worktree.ID)` (`supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift`) — - selects the worktree (or canvas-focuses it when Canvas is showing) and sends - `TerminalClient.Command.createTabInDirectory(worktree, directory: worktree.workingDirectory)` - so the tab opens at the worktree root instead of inheriting the focused surface's cwd. + selects the worktree and dispatches `TerminalClient.Command.createTabInDirectory` in + tabbed view. In Canvas it synchronously creates the root-directory tab through + `TerminalClient.createTabInDirectory`, then requests focus for the returned tab ID so a + pre-existing card cannot win the focus race. - `supacode/Features/Repositories/Views/RepositorySectionView.swift` — hover `…` menu and header context menu share one `headerMenuItems` builder: git repos get New Worktree / Repo Settings… / Remove Repository; plain folders and workspaces get Copy Path / Reveal diff --git a/docs-ai/052-sidebar-context-menus/002-canvas-new-tab-focus.md b/docs-ai/052-sidebar-context-menus/002-canvas-new-tab-focus.md new file mode 100644 index 00000000..ba714336 --- /dev/null +++ b/docs-ai/052-sidebar-context-menus/002-canvas-new-tab-focus.md @@ -0,0 +1,25 @@ +# 052.002 — Canvas New Tab Focus + +## Context + +In Canvas, `New Terminal Tab` created its tab concurrently with a worktree-level +focus request. The focus resolver could consume that request against an existing +card before the new tab existed, leaving Canvas focused on the wrong tab. + +## Change + +- `TerminalClient.createTabInDirectory` creates and selects the tab synchronously + for the Canvas path, returning its `TerminalTabID`. +- `RepositoriesFeature` sends `newTerminalTabCreatedInCanvas` only after creation, + then requests `CanvasFocusRequest.Target.tab` for that exact ID. +- The regular tabbed-view path keeps its existing asynchronous command dispatch. + +## Refs + +PR #614 + +## Current state + +Canvas `New Terminal Tab` now targets the newly created card at the worktree root. +`RepositoriesFeatureTests.newTerminalTabInCanvasCreatesAndFocusesNewCanvasTab` +verifies both the root-directory creation request and the exact-tab focus target. diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index 11889fc2..2071a662 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -352,6 +352,9 @@ struct SupacodeApp: App { send: { command in terminalManager.handleCommand(command) }, + createTabInDirectory: { worktree, directory in + terminalManager.createTabInDirectory(worktree, directory: directory) + }, events: { terminalManager.eventStream() }, diff --git a/supacode/Clients/Terminal/TerminalClient.swift b/supacode/Clients/Terminal/TerminalClient.swift index 03a3c585..210e97a7 100644 --- a/supacode/Clients/Terminal/TerminalClient.swift +++ b/supacode/Clients/Terminal/TerminalClient.swift @@ -3,6 +3,8 @@ import Foundation struct TerminalClient { var send: @MainActor @Sendable (Command) -> Void + /// Creates and selects a tab synchronously so Canvas can target its exact ID. + var createTabInDirectory: @MainActor @Sendable (Worktree, URL) -> TerminalTabID? var events: @MainActor @Sendable () -> AsyncStream var canvasFocusedWorktreeID: @MainActor @Sendable () -> Worktree.ID? /// Active surface in the selected tab. Lets the reducer capture the target @@ -92,6 +94,7 @@ struct TerminalClient { extension TerminalClient: DependencyKey { static let liveValue = TerminalClient( send: { _ in fatalError("TerminalClient.send not configured") }, + createTabInDirectory: { _, _ in fatalError("TerminalClient.createTabInDirectory not configured") }, events: { fatalError("TerminalClient.events not configured") }, canvasFocusedWorktreeID: { nil }, selectedSurfaceID: { _ in nil }, @@ -107,6 +110,7 @@ extension TerminalClient: DependencyKey { static let testValue = TerminalClient( send: { _ in }, + createTabInDirectory: { _, _ in nil }, events: { AsyncStream { $0.finish() } }, canvasFocusedWorktreeID: { nil }, selectedSurfaceID: { _ in nil }, diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift index e6186c4d..306a5ae3 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift @@ -632,15 +632,30 @@ extension RepositoriesFeature { // Pin the new tab's cwd to the worktree root: Ghostty's default new-tab // behavior inherits the focused surface's cwd, which is wrong when the // user targets a worktree from the sidebar. + if state.isShowingCanvas { + return .run { send in + guard + let tabID = await terminalClient.createTabInDirectory( + worktree, + worktree.workingDirectory + ) + else { + return + } + await send(.newTerminalTabCreatedInCanvas(worktreeID, tabID)) + } + } let createTab: Effect = .run { _ in await terminalClient.send( .createTabInDirectory(worktree, directory: worktree.workingDirectory)) } - if state.isShowingCanvas { - return .merge(.send(.focusCanvasWorktree(worktreeID)), createTab) - } return .merge(.send(.selectWorktree(worktreeID, focusTerminal: true)), createTab) + case .newTerminalTabCreatedInCanvas(let worktreeID, let tabID): + guard state.isShowingCanvas, state.worktree(for: worktreeID) != nil else { return .none } + requestCanvasFocus(.tab(tabID), openedWorktreeID: worktreeID, state: &state) + return .none + case .focusCanvasRepository(let repositoryID): guard state.isShowingCanvas, let worktree = state.canvasNavigationWorktree(forRepositoryID: repositoryID) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 685aba9d..f252bd6f 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -452,6 +452,7 @@ struct RepositoriesFeature { case openWorkspaceChild(String) case selectWorktree(Worktree.ID?, focusTerminal: Bool = false, recordHistory: Bool = true) case newTerminalTab(Worktree.ID) + case newTerminalTabCreatedInCanvas(Worktree.ID, TerminalTabID) case focusCanvasRepository(Repository.ID) case focusCanvasWorktree(Worktree.ID) case selectNextWorktree diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift index 45e0b938..0d951483 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift @@ -55,6 +55,11 @@ final class WorktreeTerminalManager { handleManagementCommand(command) } + /// Creates a tab at an explicit directory and returns its ID for immediate Canvas selection. + func createTabInDirectory(_ worktree: Worktree, directory: URL) -> TerminalTabID? { + createTabAsync(in: worktree, runSetupScriptIfNew: false, workingDirectory: directory) + } + private func handleTabCommand(_ command: TerminalClient.Command) -> Bool { switch command { case .createTab(let worktree, let runSetupScriptIfNew): @@ -302,6 +307,7 @@ final class WorktreeTerminalManager { return state } + @discardableResult private func createTabAsync( in worktree: Worktree, runSetupScriptIfNew: Bool, @@ -310,7 +316,7 @@ final class WorktreeTerminalManager { autoCloseOnSuccess: Bool = false, customCommandName: String? = nil, customCommandIcon: String? = nil - ) { + ) -> TerminalTabID? { let state = state(for: worktree) { runSetupScriptIfNew } let setupScript: String? // Skip setup injection when auto-close is requested so the setup script's @@ -338,6 +344,7 @@ final class WorktreeTerminalManager { state.applyCustomCommandIcon(customCommandIcon, surfaceId: surfaceId) } } + return tabId } private func createSplitAsync( diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 1a82b516..65f6bf95 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -2587,45 +2587,35 @@ struct RepositoriesFeatureTests { ) } - @Test func newTerminalTabInCanvasFocusesCanvasWorktreeWithoutLeavingCanvas() async { + @Test func newTerminalTabInCanvasCreatesAndFocusesNewCanvasTab() async { let worktree = makeWorktree(id: "/tmp/repo/wt", name: "wt") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) + let createdTabID = TerminalTabID(rawValue: UUID()) var initialState = makeState(repositories: [repository]) initialState.selection = .canvas - let sentCommands = LockIsolated<[TerminalClient.Command]>([]) let store = TestStore(initialState: initialState) { RepositoriesFeature() } withDependencies: { - $0.terminalClient.send = { command in - sentCommands.withValue { $0.append(command) } + $0.terminalClient.createTabInDirectory = { actualWorktree, actualDirectory in + #expect(actualWorktree == worktree) + #expect(actualDirectory == worktree.workingDirectory) + return createdTabID } } await store.send(.newTerminalTab(worktree.id)) - await store.receive(\.focusCanvasWorktree) { + await store.receive(\.newTerminalTabCreatedInCanvas) { $0.nextCanvasFocusRequestID = 1 $0.pendingCanvasFocusRequest = CanvasFocusRequest( id: 1, - target: .worktree(worktree.id) + target: .tab(createdTabID) ) $0.openedWorktreeIDs = [worktree.id] } await store.finish() #expect(store.state.selection == .canvas) - // The two effects run concurrently, so assert membership rather than order. - #expect(sentCommands.value.count == 2) - #expect( - sentCommands.value.contains( - .createTabInDirectory(worktree, directory: worktree.workingDirectory) - ) - ) - #expect( - sentCommands.value.contains( - .ensureInitialTab(worktree, runSetupScriptIfNew: false, focusing: false) - ) - ) } @Test func newTerminalTabWithUnknownWorktreeDoesNothing() async { -- 2.51.2