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 {