From 2f384ffda04d4e1ba95989c1e511acceb5ba78f7 Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 19 Aug 2026 21:57:42 +0900 Subject: [PATCH] Add per-repository diff for workspace children MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workspace child rows showed live +N/-M line counts but offered no way to open the diff: the child rows passed a nil diff callback and every diff entry point (badge delegate, ⌘⇧Y, View menu, Command Palette) was keyed on Worktree.ID, which is nil while a workspace child is selected. Introduce DiffTarget/DiffTargetID to separate the Git target from the terminal host, and route all diff entry points through it: - Child rows gain a working diff badge plus Show Diff / Show Outgoing Changes context-menu items. - ⌘⇧Y, ⌘⌥⇧Y, the View menu, and the Command Palette follow the selected workspace child via a new selectedDiffTargetID query. - ExternalDiffToolClient, OutgoingChangesClient, and the snapshot client take a DiffTarget; the Hunk tool opens its tab in the workspace's own terminal with the child directory as cwd, so no terminal state exists outside the workspace lifecycle. - Outgoing Changes resolves the child's PR from the per-child info cache; base resolution reuses the existing ladder. - Child branch display falls back live branch → metadata branch → repository name; a broken child path degrades to the existing "Unable to open diff" error alert. - The +N/-M badge renders as plain text (no dead button or Show Diff tooltip) when no diff action is wired. Fixes #616 Claude-Session: https://claude.ai/code/session_01WoqhZai4i4izqbUZLtbjde --- docs-ai/062-workspace-child-diff/000-plan.md | 87 ++++++++++++ docs-ai/README.md | 1 + docs/components/diff-view.md | 7 + docs/components/workspaces.md | 12 +- supacode/App/supacodeApp.swift | 11 +- .../ExternalDiffSnapshotClient.swift | 14 +- .../ExternalDiff/ExternalDiffToolClient.swift | 45 +++--- .../ExternalDiff/OutgoingChangesClient.swift | 31 +++-- supacode/Commands/SidebarCommands.swift | 4 +- supacode/Domain/DiffTarget.swift | 41 ++++++ .../Reducer/AppFeature+CommandPalette.swift | 20 +-- .../Features/App/Reducer/AppFeature.swift | 10 +- .../Reducer/CommandPaletteFeature.swift | 6 +- .../RepositoriesFeature+StateQueries.swift | 38 ++++++ .../Reducer/RepositoriesFeature.swift | 4 +- .../Views/RepositorySectionView.swift | 6 + .../Views/WorkspaceChildRowsView.swift | 13 +- .../Repositories/Views/WorktreeRow.swift | 40 +++--- .../Repositories/Views/WorktreeRowsView.swift | 6 +- .../AppFeatureCommandPaletteTests.swift | 128 +++++++++++++++--- supacodeTests/ExternalDiffToolTests.swift | 60 ++++++-- supacodeTests/RepositoriesFeatureTests.swift | 94 +++++++++++++ 22 files changed, 561 insertions(+), 117 deletions(-) create mode 100644 docs-ai/062-workspace-child-diff/000-plan.md create mode 100644 supacode/Domain/DiffTarget.swift diff --git a/docs-ai/062-workspace-child-diff/000-plan.md b/docs-ai/062-workspace-child-diff/000-plan.md new file mode 100644 index 00000000..68ab6825 --- /dev/null +++ b/docs-ai/062-workspace-child-diff/000-plan.md @@ -0,0 +1,87 @@ +# 062 — Workspace Child Per-Repository Diff: Plan + +| | | +| --- | --- | +| **Status** | Planned | +| **Anchor date** | 2026-08-19 | +| **Primary PRs** | (fill in as they merge) | +| **Related** | [042-project-workspaces](../042-project-workspaces/000-plan.md), `docs/` diff & workspace pages, issue #616 | + +## Background + +Workspace child repositories show live per-repository status — branch, `+N/-M` line +changes, and PR info — but none of the diff entry points work for them (issue #616). +The child rows pass `onDiffTap: nil` (`supacode/Features/Repositories/Views/WorkspaceChildRowsView.swift`), +and the whole diff pipeline is keyed on `Worktree.ID`: the sidebar badge delegate +(`RepositoriesFeature.Delegate.showDiff(Worktree.ID)`), the ⌘⇧Y menu command and the +Command Palette items are all gated on `selectedWorktreeID`, which is `nil` while a +workspace child is selected (selection stays `.repository(workspaceID)` + +`selectedWorkspaceChildID`). This was a deliberate v1 scope cut — children are metadata +entries, not tracked worktrees (see entry 042) — not a regression, but it blocks the +core review flow for multi-repo tasks. + +## Goals + +- Clicking a workspace child's `+N/-M` badge opens the diff for that child repository. +- With a child selected, ⌘⇧Y / "Show Diff" and ⌘⇧U / "Show Outgoing Changes" (menu and + Command Palette) target that child, matching single-repo behavior. +- All configured diff tools behave consistently for children: built-in window, FileMerge, + Kaleidoscope, custom command, and Hunk. +- Hunk runs in the workspace's own terminal (a new tab with the child directory as cwd), + so no terminal state exists outside the workspace's lifecycle. +- Built-in diff supports Outgoing Changes for children, using the child's already-fetched + PR info (`workspaceChildInfoByID`) with the existing base-resolution ladder. + +### Non-goals + +- Promoting workspace children to first-class `Worktree`s (conflicts with the single-host + terminal design from entry 042; not needed for diff). +- Path-validity pre-checks: when a child path is gone or not a git repo, git commands fail + naturally and surface the existing "Unable to open diff" error alert. + +## Design / Approach + +Introduce a resolved diff request type and a stable reference for routing: + +- `DiffTargetID`: `case worktree(Worktree.ID)` | `case workspaceChild(String)` (child id = + working-directory path, same key as `workspaceChildInfoByID`). +- `DiffTarget`: `id`, `workingDirectory` (git dir to diff), `branchName` (window title + + `{branch}` template; fallback live branch → metadata branch → repository name), + `repositoryRootURL` (`{repoPath}` template + `repositorySettings` key), `terminalHost: + Worktree` (Hunk tab host — the synthesized workspace worktree for children), and + `terminalWorkingDirectory: URL?` (Hunk cwd override). + +Wiring: + +- `RepositoriesFeature+StateQueries`: `diffTarget(for: DiffTargetID) -> DiffTarget?` and + `selectedDiffTargetID` (selected worktree, else selected workspace child). +- `RepositoriesFeature.Delegate.showDiff` / `.showOutgoingChanges` carry `DiffTargetID`. +- `WorkspaceChildRowsView` gains an `onDiffTap` per row, wired in `RepositorySectionView`. +- `SidebarCommands` and the Command Palette item builder gate the two view items on + `selectedDiffTargetID` instead of `selectedWorktreeID` (navigation/action items keep the + worktree gate). +- `ExternalDiffToolClient.open` takes a `DiffTarget`; the Hunk case sends + `.createTabWithInput(target.terminalHost, workingDirectory: target.terminalWorkingDirectory, …)` + (parameter already exists on `TerminalClient.Command`). +- `ExternalDiffSnapshotClient` takes the working-directory `URL` (its only input today). +- `OutgoingChangesClient` resolves per `DiffTarget`; its `pullRequestInfo` lookup is keyed + by `DiffTargetID` and wired in `supacodeApp` to `worktreeInfoByID` / + `workspaceChildInfoByID` respectively. Child `repositorySettings` are keyed by the child + root, so a child that is also registered standalone honors its configured base ref. +- `WorktreeRow` renders the `+N/-M` badge as non-interactive (no "Show Diff" help) when + `onDiffTap == nil`, removing the current dead-button affordance. + +## Alternatives & decisions + +- **Synthesize a fake child `Worktree`** instead of `DiffTarget`: less churn, but the two + ID-keyed lookups (PR cache, terminal state) would silently miss or orphan; rejected. +- **Badge-only support** (no ⌘⇧Y/palette): smaller, but leaves inconsistent entry points + that would need a follow-up anyway; rejected with onevcat. +- **Skip Hunk for children**: avoids terminal questions, but silently bypasses the user's + configured tool; rejected — `createTabWithInput`'s `workingDirectory` parameter makes the + consistent behavior cheap. +- **Path pre-checks / disabled states for broken children**: rejected in favor of natural + degradation (no line changes → no badge; explicit invocation → existing error alert). + +## Amendments + diff --git a/docs-ai/README.md b/docs-ai/README.md index e4e43dd5..c13acc79 100644 --- a/docs-ai/README.md +++ b/docs-ai/README.md @@ -113,3 +113,4 @@ agent-facing manual for that). | 059 | [agent-transcript-snapshots](059-agent-transcript-snapshots/000-plan.md) | 2026-08-11 | Immediate Codex/Claude agent snapshots with trustworthy transcript results and actionable blocker text | | 060 | [prowl-cli-targeting-and-contract-governance](060-prowl-cli-targeting-and-contract-governance/000-plan.md) | 2026-08-16 | Unified target grammar, CLI contract rebaseline, and durable documentation governance | | 061 | [native-toolbar-controls](061-native-toolbar-controls/000-plan.md) | 2026-08-17 | Native macOS toolbar grouping, Liquid Glass ownership, and review standards | +| 062 | [workspace-child-diff](062-workspace-child-diff/000-plan.md) | 2026-08-19 | Per-repository diff for workspace children via unified DiffTarget routing | diff --git a/docs/components/diff-view.md b/docs/components/diff-view.md index 0b770c51..4bdbd664 100644 --- a/docs/components/diff-view.md +++ b/docs/components/diff-view.md @@ -16,6 +16,11 @@ fast way to review before committing or merging. **Open:** click a worktree's diff badge, press `⌘⇧Y` (`show_diff`), use Command Palette → "Show Diff", or right-click a worktree row → "Show Diff". +The same entry points work for **workspace child repositories**: the child +row's diff badge, its context menu, and — with a child selected — `⌘⇧Y`, +`⌘⌥⇧Y`, and the Command Palette all target that child's repository. The Hunk +tool opens its tab in the workspace's terminal, rooted at the child folder. + ## Outgoing Changes **Outgoing Changes** is the second mode of the same window: the committed @@ -105,6 +110,8 @@ every changed file. Tracked additions and deletions remain exact. ## Availability Diff is a **git-only** feature — it's unavailable for plain (non-git) folders. +A workspace root is a plain folder, so it has no diff of its own; diff is +available per child repository inside it. ## Gotchas for agents diff --git a/docs/components/workspaces.md b/docs/components/workspaces.md index 697a3de3..4412318e 100644 --- a/docs/components/workspaces.md +++ b/docs/components/workspaces.md @@ -14,7 +14,8 @@ When you open a workspace in Prowl: - The `prowl` CLI reports the runnable target's `worktree.kind` as `workspace`. - Git worktree, branch, diff, and PR controls remain per-repository features; a workspace is intentionally a multi-repo working directory rather than a single - git repository. + git repository. Diff opens per child repository from its sidebar row (see + below). ## Folder layout @@ -110,7 +111,14 @@ 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**. +Right-click a child row for **Copy Path** / **Reveal in Finder** / +**Show Diff** / **Show Outgoing Changes**. + +Diff works per child repository: click the child's `+N/-M` badge to open the +diff for that repository, or, with a child selected, use `⌘⇧Y` (Show Diff), +`⌘⌥⇧Y` (Show Outgoing Changes), or the matching Command Palette items. All +configured diff tools apply; the Hunk tool opens a workspace terminal tab +rooted at the child folder. See [diff-view](diff-view.md). ## Removing a workspace diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index 66836fb9..dde7c359 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -280,8 +280,15 @@ struct SupacodeApp: App { storeBox: SupacodeAppStoreBox ) -> OutgoingChangesClient { .live( - pullRequestInfo: { worktreeID in - storeBox.store?.withState { $0.repositories.worktreeInfo(for: worktreeID)?.pullRequest } ?? nil + pullRequestInfo: { targetID in + storeBox.store?.withState { state in + switch targetID { + case .worktree(let worktreeID): + state.repositories.worktreeInfo(for: worktreeID)?.pullRequest + case .workspaceChild(let childID): + state.repositories.workspaceChildInfoByID[childID]?.pullRequest + } + } ?? nil } ) } diff --git a/supacode/Clients/ExternalDiff/ExternalDiffSnapshotClient.swift b/supacode/Clients/ExternalDiff/ExternalDiffSnapshotClient.swift index c07f4efa..109b9f13 100644 --- a/supacode/Clients/ExternalDiff/ExternalDiffSnapshotClient.swift +++ b/supacode/Clients/ExternalDiff/ExternalDiffSnapshotClient.swift @@ -7,12 +7,12 @@ nonisolated struct ExternalDiffSnapshotPair: Equatable, Sendable { } nonisolated struct ExternalDiffSnapshotClient: Sendable { - var makeSnapshotPair: @Sendable (Worktree) async throws -> ExternalDiffSnapshotPair + var makeSnapshotPair: @Sendable (_ workingDirectory: URL) async throws -> ExternalDiffSnapshotPair } extension ExternalDiffSnapshotClient: DependencyKey { - static let liveValue = ExternalDiffSnapshotClient { worktree in - try await ExternalDiffSnapshotBuilder().makeSnapshotPair(for: worktree) + static let liveValue = ExternalDiffSnapshotClient { workingDirectory in + try await ExternalDiffSnapshotBuilder().makeSnapshotPair(at: workingDirectory) } static let testValue = ExternalDiffSnapshotClient { _ in @@ -31,10 +31,10 @@ extension DependencyValues { } private nonisolated struct ExternalDiffSnapshotBuilder { - func makeSnapshotPair(for worktree: Worktree) async throws -> ExternalDiffSnapshotPair { + func makeSnapshotPair(at workingDirectory: URL) async throws -> ExternalDiffSnapshotPair { let gitClient = GitClient() - async let trackedOutput = gitClient.diffNameStatus(at: worktree.workingDirectory) - async let untrackedPaths = gitClient.untrackedFilePaths(at: worktree.workingDirectory) + async let trackedOutput = gitClient.diffNameStatus(at: workingDirectory) + async let untrackedPaths = gitClient.untrackedFilePaths(at: workingDirectory) let trackedFiles = DiffChangedFile.parseNameStatus(await trackedOutput) let untrackedFiles = await untrackedPaths.map { DiffChangedFile(status: .added, oldPath: nil, newPath: $0) @@ -51,7 +51,7 @@ private nonisolated struct ExternalDiffSnapshotBuilder { try FileManager.default.createDirectory(at: rightURL, withIntermediateDirectories: true) for file in files { - try copySnapshotFile(file, from: worktree.workingDirectory, leftURL: leftURL, rightURL: rightURL) + try copySnapshotFile(file, from: workingDirectory, leftURL: leftURL, rightURL: rightURL) } return ExternalDiffSnapshotPair(leftURL: leftURL, rightURL: rightURL) diff --git a/supacode/Clients/ExternalDiff/ExternalDiffToolClient.swift b/supacode/Clients/ExternalDiff/ExternalDiffToolClient.swift index d8d3effa..7e0fc7f0 100644 --- a/supacode/Clients/ExternalDiff/ExternalDiffToolClient.swift +++ b/supacode/Clients/ExternalDiff/ExternalDiffToolClient.swift @@ -5,14 +5,14 @@ nonisolated struct ExternalDiffToolClient: Sendable { var open: @MainActor @Sendable ( _ settings: ExternalDiffSettings, - _ worktree: Worktree, + _ target: DiffTarget, _ resolvedKeybindings: ResolvedKeybindingMap, _ onError: @escaping @MainActor @Sendable (OpenActionError) -> Void ) async -> Void } extension ExternalDiffToolClient: DependencyKey { - static let liveValue = ExternalDiffToolClient { settings, worktree, resolvedKeybindings, onError in + static let liveValue = ExternalDiffToolClient { settings, target, resolvedKeybindings, onError in @Dependency(TerminalClient.self) var terminalClient @Dependency(ShellClient.self) var shellClient @Dependency(ExternalDiffSnapshotClient.self) var snapshotClient @@ -22,21 +22,28 @@ extension ExternalDiffToolClient: DependencyKey { case .builtIn: @Shared(.settingsFile) var settingsFile DiffWindowManager.shared.show( - worktreeURL: worktree.workingDirectory, - branchName: worktree.name, - outgoingResolver: outgoingChangesClient.makeResolver(worktree), + worktreeURL: target.workingDirectory, + branchName: target.branchName, + outgoingResolver: outgoingChangesClient.makeResolver(target), resolvedKeybindings: resolvedKeybindings, colorScheme: settingsFile.global.appearanceMode.colorScheme ) case .hunk: + // A cwd override means the diff runs somewhere other than the host's own + // directory (a workspace child); name the tab after that repository. + let commandName = + target.terminalWorkingDirectory == nil + ? "Hunk Diff" + : "Hunk Diff · \(target.workingDirectory.lastPathComponent)" await terminalClient.send( .createTabWithInput( - worktree, + target.terminalHost, input: "hunk diff", + workingDirectory: target.terminalWorkingDirectory, runSetupScriptIfNew: false, autoCloseOnSuccess: false, - customCommandName: "Hunk Diff", + customCommandName: commandName, customCommandIcon: "square.split.2x1" ) ) @@ -44,7 +51,7 @@ extension ExternalDiffToolClient: DependencyKey { case .fileMerge: await runGUICommand( ExternalDiffGUICommandRequest(tool: settings.tool, executableName: "opendiff", arguments: []), - worktree: worktree, + target: target, shellClient: shellClient, snapshotClient: snapshotClient, onError: onError @@ -53,7 +60,7 @@ extension ExternalDiffToolClient: DependencyKey { case .kaleidoscope: await runGUICommand( ExternalDiffGUICommandRequest(tool: settings.tool, executableName: "ksdiff", arguments: ["--diff"]), - worktree: worktree, + target: target, shellClient: shellClient, snapshotClient: snapshotClient, onError: onError @@ -62,7 +69,7 @@ extension ExternalDiffToolClient: DependencyKey { case .custom: await runCustomCommand( settings: settings, - worktree: worktree, + target: target, shellClient: shellClient, snapshotClient: snapshotClient, onError: onError @@ -88,13 +95,13 @@ private struct ExternalDiffGUICommandRequest { private func runGUICommand( _ request: ExternalDiffGUICommandRequest, - worktree: Worktree, + target: DiffTarget, shellClient: ShellClient, snapshotClient: ExternalDiffSnapshotClient, onError: @escaping @MainActor @Sendable (OpenActionError) -> Void ) async { do { - let snapshot = try await snapshotClient.makeSnapshotPair(worktree) + let snapshot = try await snapshotClient.makeSnapshotPair(target.workingDirectory) let executableURL = URL(fileURLWithPath: "/usr/bin/env") _ = try await shellClient.runLogin( executableURL, @@ -102,7 +109,7 @@ private func runGUICommand( snapshot.leftURL.path(percentEncoded: false), snapshot.rightURL.path(percentEncoded: false), ], - worktree.workingDirectory + target.workingDirectory ) } catch { onError(openError(for: request.tool, error: error)) @@ -111,7 +118,7 @@ private func runGUICommand( private func runCustomCommand( settings: ExternalDiffSettings, - worktree: Worktree, + target: DiffTarget, shellClient: ShellClient, snapshotClient: ExternalDiffSnapshotClient, onError: @escaping @MainActor @Sendable (OpenActionError) -> Void @@ -127,11 +134,11 @@ private func runCustomCommand( return } do { - let snapshot = try await snapshotClient.makeSnapshotPair(worktree) + let snapshot = try await snapshotClient.makeSnapshotPair(target.workingDirectory) let context = ExternalDiffCommandContext( - worktreePath: worktree.workingDirectory.path(percentEncoded: false), - repoPath: worktree.repositoryRootURL.path(percentEncoded: false), - branch: worktree.name, + worktreePath: target.workingDirectory.path(percentEncoded: false), + repoPath: target.repositoryRootURL.path(percentEncoded: false), + branch: target.branchName, leftPath: snapshot.leftURL.path(percentEncoded: false), rightPath: snapshot.rightURL.path(percentEncoded: false) ) @@ -139,7 +146,7 @@ private func runCustomCommand( _ = try await shellClient.runLogin( URL(fileURLWithPath: "/bin/zsh"), ["-lc", command], - worktree.workingDirectory + target.workingDirectory ) } catch { onError(openError(for: settings.tool, error: error)) diff --git a/supacode/Clients/ExternalDiff/OutgoingChangesClient.swift b/supacode/Clients/ExternalDiff/OutgoingChangesClient.swift index e07f9961..e79d5189 100644 --- a/supacode/Clients/ExternalDiff/OutgoingChangesClient.swift +++ b/supacode/Clients/ExternalDiff/OutgoingChangesClient.swift @@ -11,45 +11,46 @@ typealias OutgoingComparisonResolver = @Sendable () async throws -> GitOutgoingC nonisolated struct OutgoingChangesClient: Sendable { var open: @MainActor @Sendable ( - _ worktree: Worktree, + _ target: DiffTarget, _ resolvedKeybindings: ResolvedKeybindingMap, _ onError: @escaping @MainActor @Sendable (OpenActionError) -> Void ) async -> Void - var makeResolver: @MainActor @Sendable (_ worktree: Worktree) -> OutgoingComparisonResolver + var makeResolver: @MainActor @Sendable (_ target: DiffTarget) -> OutgoingComparisonResolver } extension OutgoingChangesClient { - /// `pullRequestInfo` reads the currently cached pull request for a worktree; - /// the app wires it to live store state so resolvers observe pull request - /// changes that happen after the diff window was opened. + /// `pullRequestInfo` reads the currently cached pull request for a diff + /// target (a worktree or a workspace child); the app wires it to live store + /// state so resolvers observe pull request changes that happen after the + /// diff window was opened. static func live( - pullRequestInfo: @escaping @MainActor @Sendable (Worktree.ID) -> GithubPullRequest? + pullRequestInfo: @escaping @MainActor @Sendable (DiffTargetID) -> GithubPullRequest? ) -> Self { - let makeResolver: @MainActor @Sendable (Worktree) -> OutgoingComparisonResolver = { worktree in + let makeResolver: @MainActor @Sendable (DiffTarget) -> OutgoingComparisonResolver = { target in { - let pullRequest = await pullRequestInfo(worktree.id) + let pullRequest = await pullRequestInfo(target.id) let pullRequestBase = pullRequest.map { GitPullRequestBase(url: $0.url, baseRefName: $0.baseRefName ?? "") } - @Shared(.repositorySettings(worktree.repositoryRootURL)) var repositorySettings + @Shared(.repositorySettings(target.repositoryRootURL)) var repositorySettings let gitClient = GitClient() let base = try await gitClient.outgoingBaseResolution( pullRequest: pullRequestBase, configuredBaseRef: repositorySettings.worktreeBaseRef, - in: worktree.workingDirectory + in: target.workingDirectory ) - return try await gitClient.outgoingChangesComparison(base: base, at: worktree.workingDirectory) + return try await gitClient.outgoingChangesComparison(base: base, at: target.workingDirectory) } } return OutgoingChangesClient( - open: { worktree, resolvedKeybindings, onError in - let resolver = makeResolver(worktree) + open: { target, resolvedKeybindings, onError in + let resolver = makeResolver(target) do { let comparison = try await resolver() @Shared(.settingsFile) var settingsFile DiffWindowManager.shared.show( - worktreeURL: worktree.workingDirectory, - branchName: worktree.name, + worktreeURL: target.workingDirectory, + branchName: target.branchName, comparison: .outgoing(comparison), outgoingResolver: resolver, resolvedKeybindings: resolvedKeybindings, diff --git a/supacode/Commands/SidebarCommands.swift b/supacode/Commands/SidebarCommands.swift index d176b448..742eeb75 100644 --- a/supacode/Commands/SidebarCommands.swift +++ b/supacode/Commands/SidebarCommands.swift @@ -77,13 +77,13 @@ struct SidebarCommands: Commands { } .modifier(KeyboardShortcutModifier(shortcut: keyboardShortcut(for: AppShortcuts.CommandID.showDiff))) .help(helpText(title: "Show Diff", commandID: AppShortcuts.CommandID.showDiff)) - .disabled(store.repositories.selectedWorktreeID == nil) + .disabled(store.repositories.selectedDiffTargetID == nil) Button("Show Outgoing Changes", systemImage: "arrow.up.right") { store.send(.showSelectedWorktreeOutgoingChanges) } .modifier(KeyboardShortcutModifier(shortcut: keyboardShortcut(for: AppShortcuts.CommandID.outgoingChanges))) .help(helpText(title: "Show Outgoing Changes", commandID: AppShortcuts.CommandID.outgoingChanges)) - .disabled(store.repositories.selectedWorktreeID == nil) + .disabled(store.repositories.selectedDiffTargetID == nil) } } diff --git a/supacode/Domain/DiffTarget.swift b/supacode/Domain/DiffTarget.swift new file mode 100644 index 00000000..3dc6dbce --- /dev/null +++ b/supacode/Domain/DiffTarget.swift @@ -0,0 +1,41 @@ +import Foundation + +/// Stable reference to something the diff pipeline can act on: a tracked +/// worktree, or a workspace child repository keyed by its working-directory +/// path (the same key as `workspaceChildInfoByID`). +nonisolated enum DiffTargetID: Hashable, Sendable { + case worktree(Worktree.ID) + case workspaceChild(String) +} + +/// A resolved diff request. Separates the Git target (the directory whose +/// changes are diffed) from the terminal host (where the Hunk tool creates +/// its tab): for a workspace child the Git target is the child repository +/// while the terminal host stays the workspace, so no terminal state exists +/// outside the workspace's lifecycle. +nonisolated struct DiffTarget: Equatable, Sendable { + let id: DiffTargetID + /// Directory whose working-tree changes are diffed. + let workingDirectory: URL + /// Display branch; also fills `{branch}` in custom diff command templates. + let branchName: String + /// Fills `{repoPath}` in custom templates and keys `repositorySettings`. + let repositoryRootURL: URL + /// Worktree owning the terminal that the Hunk tool runs in. + let terminalHost: Worktree + /// Hunk cwd when it differs from the host's own directory. + let terminalWorkingDirectory: URL? +} + +extension DiffTarget { + init(worktree: Worktree) { + self.init( + id: .worktree(worktree.id), + workingDirectory: worktree.workingDirectory, + branchName: worktree.name, + repositoryRootURL: worktree.repositoryRootURL, + terminalHost: worktree, + terminalWorkingDirectory: nil + ) + } +} diff --git a/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift b/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift index ae848d2d..45ec47ac 100644 --- a/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift +++ b/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift @@ -191,7 +191,7 @@ extension AppFeature { } func openDiffEffect( - worktree: Worktree, + target: DiffTarget, resolvedKeybindings: ResolvedKeybindingMap ) -> Effect { @Shared(.settingsFile) var settingsFile @@ -200,35 +200,35 @@ extension AppFeature { customCommand: settingsFile.global.externalDiffCustomCommand ) return .run { send in - await externalDiffToolClient.open(settings, worktree, resolvedKeybindings) { error in + await externalDiffToolClient.open(settings, target, resolvedKeybindings) { error in send(.openWorktreeFailed(error)) } } } func openSelectedWorktreeDiffEffect(state: State) -> Effect { - guard let worktreeID = state.repositories.selectedWorktreeID, - let worktree = state.repositories.worktree(for: worktreeID) + guard let targetID = state.repositories.selectedDiffTargetID, + let target = state.repositories.diffTarget(for: targetID) else { return .none } - return openDiffEffect(worktree: worktree, resolvedKeybindings: state.resolvedKeybindings) + return openDiffEffect(target: target, resolvedKeybindings: state.resolvedKeybindings) } func openSelectedWorktreeOutgoingChangesEffect(state: State) -> Effect { - guard let worktreeID = state.repositories.selectedWorktreeID else { + guard let targetID = state.repositories.selectedDiffTargetID else { return .none } - return openOutgoingChangesEffect(worktreeID: worktreeID, state: state) + return openOutgoingChangesEffect(targetID: targetID, state: state) } - func openOutgoingChangesEffect(worktreeID: Worktree.ID, state: State) -> Effect { - guard let worktree = state.repositories.worktree(for: worktreeID) else { + func openOutgoingChangesEffect(targetID: DiffTargetID, state: State) -> Effect { + guard let target = state.repositories.diffTarget(for: targetID) else { return .none } let resolvedKeybindings = state.resolvedKeybindings return .run { send in - await outgoingChangesClient.open(worktree, resolvedKeybindings) { error in + await outgoingChangesClient.open(target, resolvedKeybindings) { error in send(.openWorktreeFailed(error)) } } diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 84a8d3f8..037394f1 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -363,14 +363,14 @@ struct AppFeature { let selection = SettingsSection.repository(repositoryID) return openSettingsEffect(selecting: selection) - case .repositories(.delegate(.showDiff(let worktreeID))): - guard let worktree = state.repositories.worktree(for: worktreeID) else { + case .repositories(.delegate(.showDiff(let targetID))): + guard let target = state.repositories.diffTarget(for: targetID) else { return .none } - return openDiffEffect(worktree: worktree, resolvedKeybindings: state.resolvedKeybindings) + return openDiffEffect(target: target, resolvedKeybindings: state.resolvedKeybindings) - case .repositories(.delegate(.showOutgoingChanges(let worktreeID))): - return openOutgoingChangesEffect(worktreeID: worktreeID, state: state) + case .repositories(.delegate(.showOutgoingChanges(let targetID))): + return openOutgoingChangesEffect(targetID: targetID, state: state) case .settings(.setSelection(let selection)): let resolvedSelection = selection ?? .general diff --git a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift index 2c082225..d95e42c0 100644 --- a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift +++ b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift @@ -240,8 +240,12 @@ struct CommandPaletteFeature { items.append(contentsOf: canvasCommandItems()) } let worktreeActionTargetID = actionTargetWorktreeID ?? repositories.selectedWorktreeID - if repositories.selectedWorktreeID != nil { + // Diff view items follow the broader diff target (worktree or workspace + // child); the navigation/action items below stay worktree-scoped. + if repositories.selectedDiffTargetID != nil { items.append(contentsOf: selectedWorktreeViewCommandItems()) + } + if repositories.selectedWorktreeID != nil { items.append(contentsOf: worktreeNavigationCommandItems()) items.append( contentsOf: worktreeActionCommandItems( diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+StateQueries.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+StateQueries.swift index 84f69ce4..8996f082 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+StateQueries.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+StateQueries.swift @@ -228,6 +228,44 @@ extension RepositoriesFeature.State { } } + /// Resolves a diff entry-point reference to a concrete request. Worktrees + /// diff their own directory and host Hunk themselves; workspace children + /// diff the child repository while hosting Hunk in the workspace's terminal + /// with the child directory as cwd. Child branch falls back live branch → + /// metadata branch → repository name. + func diffTarget(for id: DiffTargetID) -> DiffTarget? { + switch id { + case .worktree(let worktreeID): + return worktree(for: worktreeID).map(DiffTarget.init(worktree:)) + case .workspaceChild(let childID): + guard let child = allResolvedWorkspaceChildren().first(where: { $0.id == childID }), + let workspaceRepository = repositories[id: child.workspaceID] + else { + return nil + } + return DiffTarget( + id: id, + workingDirectory: child.workingDirectory, + branchName: workspaceChildBranchByID[child.id] ?? child.metadataBranch ?? child.repositoryName, + repositoryRootURL: child.workingDirectory, + terminalHost: Self.plainFolderWorktree(for: workspaceRepository), + terminalWorkingDirectory: child.workingDirectory + ) + } + } + + /// The diff target that ⌘⇧Y, the View menu, and the Command Palette act on: + /// the selected worktree, else the selected workspace child. + var selectedDiffTargetID: DiffTargetID? { + if let selectedWorktreeID { + return .worktree(selectedWorktreeID) + } + if let selectedWorkspaceChildID, selectedRepository?.isWorkspace == true { + return .workspaceChild(selectedWorkspaceChildID) + } + return nil + } + struct ArchivedWorktreeGroup: Equatable { var repository: Repository var worktrees: [Worktree] diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index f91dd063..2f05bb51 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -541,8 +541,8 @@ struct RepositoriesFeature { case selectedWorktreeChanged(Worktree?) case repositoriesChanged(IdentifiedArrayOf) case openRepositorySettings(Repository.ID) - case showDiff(Worktree.ID) - case showOutgoingChanges(Worktree.ID) + case showDiff(DiffTargetID) + case showOutgoingChanges(DiffTargetID) case worktreeCreated(Worktree) } diff --git a/supacode/Features/Repositories/Views/RepositorySectionView.swift b/supacode/Features/Repositories/Views/RepositorySectionView.swift index 931e110a..550d9e07 100644 --- a/supacode/Features/Repositories/Views/RepositorySectionView.swift +++ b/supacode/Features/Repositories/Views/RepositorySectionView.swift @@ -247,6 +247,12 @@ struct RepositorySectionView: View { ? state.selectedWorkspaceChildID : nil, onSelect: { childID in store.send(.openWorkspaceChild(childID)) + }, + onShowDiff: { childID in + store.send(.delegate(.showDiff(.workspaceChild(childID)))) + }, + onShowOutgoingChanges: { childID in + store.send(.delegate(.showOutgoingChanges(.workspaceChild(childID)))) } ) } else { diff --git a/supacode/Features/Repositories/Views/WorkspaceChildRowsView.swift b/supacode/Features/Repositories/Views/WorkspaceChildRowsView.swift index 0ab1aacb..374abd28 100644 --- a/supacode/Features/Repositories/Views/WorkspaceChildRowsView.swift +++ b/supacode/Features/Repositories/Views/WorkspaceChildRowsView.swift @@ -9,6 +9,8 @@ struct WorkspaceChildRowsView: View { let rows: [WorkspaceChildRowModel] let selectedID: String? let onSelect: (String) -> Void + let onShowDiff: (String) -> Void + let onShowOutgoingChanges: (String) -> Void var body: some View { ForEach(rows) { row in @@ -33,7 +35,7 @@ struct WorkspaceChildRowsView: View { pinAction: nil, isSelected: isSelected, archiveAction: nil, - onDiffTap: nil, + onDiffTap: { onShowDiff(row.id) }, onStopRunScript: nil, ) .padding(.leading, 14) @@ -61,6 +63,15 @@ struct WorkspaceChildRowsView: View { Button("Reveal in Finder") { NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: row.workingDirectory.path) } + Divider() + Button("Show Diff") { + onShowDiff(row.id) + } + .help("Show uncommitted changes in \(row.repositoryName)") + Button("Show Outgoing Changes") { + onShowOutgoingChanges(row.id) + } + .help("Show committed changes relative to this repository's base") } .id(row.id) } diff --git a/supacode/Features/Repositories/Views/WorktreeRow.swift b/supacode/Features/Repositories/Views/WorktreeRow.swift index 95dbcec4..a5fd5a97 100644 --- a/supacode/Features/Repositories/Views/WorktreeRow.swift +++ b/supacode/Features/Repositories/Views/WorktreeRow.swift @@ -173,27 +173,35 @@ struct WorktreeRow: View { RunScriptIndicator(onStop: onStopRunScript) } if let lineChangePresentation { - Button { - onDiffTap?() - } label: { + if let onDiffTap { + Button { + onDiffTap() + } label: { + WorktreeRowChangeCountView( + presentation: lineChangePresentation, + isSelected: isSelected, + ) + } + .buttonStyle(.plain) + .help( + [ + AppShortcuts.helpText( + title: "Show Diff", + commandID: AppShortcuts.CommandID.showDiff, + in: resolvedKeybindings + ), + lineChangePresentation.incompleteCountDescription, + ] + .compactMap { $0 } + .joined(separator: "\n") + ) + } else { WorktreeRowChangeCountView( presentation: lineChangePresentation, isSelected: isSelected, ) + .help(lineChangePresentation.incompleteCountDescription ?? "") } - .buttonStyle(.plain) - .help( - [ - AppShortcuts.helpText( - title: "Show Diff", - commandID: AppShortcuts.CommandID.showDiff, - in: resolvedKeybindings - ), - lineChangePresentation.incompleteCountDescription, - ] - .compactMap { $0 } - .joined(separator: "\n") - ) } } WorktreeRowInfoView( diff --git a/supacode/Features/Repositories/Views/WorktreeRowsView.swift b/supacode/Features/Repositories/Views/WorktreeRowsView.swift index 2b2f8cbf..3a6cb3cf 100644 --- a/supacode/Features/Repositories/Views/WorktreeRowsView.swift +++ b/supacode/Features/Repositories/Views/WorktreeRowsView.swift @@ -241,7 +241,7 @@ struct WorktreeRowsView: View { private func diffTapHandler(for worktreeID: Worktree.ID) -> (() -> Void)? { { - store.send(.delegate(.showDiff(worktreeID))) + store.send(.delegate(.showDiff(.worktree(worktreeID)))) } } @@ -512,11 +512,11 @@ struct WorktreeRowsView: View { } Divider() Button("Show Diff") { - store.send(.delegate(.showDiff(worktree.id))) + store.send(.delegate(.showDiff(.worktree(worktree.id)))) } .help("Show uncommitted changes for this worktree") Button("Show Outgoing Changes") { - store.send(.delegate(.showOutgoingChanges(worktree.id))) + store.send(.delegate(.showOutgoingChanges(.worktree(worktree.id)))) } .help("Show committed changes relative to this worktree's base") Divider() diff --git a/supacodeTests/AppFeatureCommandPaletteTests.swift b/supacodeTests/AppFeatureCommandPaletteTests.swift index c564792d..2e20691c 100644 --- a/supacodeTests/AppFeatureCommandPaletteTests.swift +++ b/supacodeTests/AppFeatureCommandPaletteTests.swift @@ -903,13 +903,13 @@ struct AppFeatureCommandPaletteTests { let settingsFileURL = URL( fileURLWithPath: "/tmp/supacode-settings-\(UUID().uuidString).json" ) - let launched = LockIsolated<[(ExternalDiffSettings, Worktree)]>([]) + let launched = LockIsolated<[(ExternalDiffSettings, DiffTarget)]>([]) let store = withDependencies { $0.settingsFileStorage = storage.storage $0.settingsFileURL = settingsFileURL $0.terminalClient.send = { _ in } - $0.externalDiffToolClient.open = { settings, worktree, _, _ in - launched.withValue { $0.append((settings, worktree)) } + $0.externalDiffToolClient.open = { settings, target, _, _ in + launched.withValue { $0.append((settings, target)) } } } operation: { @Shared(.settingsFile) var settingsFile @@ -935,7 +935,7 @@ struct AppFeatureCommandPaletteTests { ) ] ) - #expect(launched.value.map(\.1) == [worktree]) + #expect(launched.value.map(\.1) == [DiffTarget(worktree: worktree)]) } @Test(.dependencies) func showSelectedWorktreeDiffUsesConfiguredExternalDiffTool() async { @@ -955,13 +955,13 @@ struct AppFeatureCommandPaletteTests { let settingsFileURL = URL( fileURLWithPath: "/tmp/supacode-settings-\(UUID().uuidString).json" ) - let launched = LockIsolated<[(ExternalDiffSettings, Worktree)]>([]) + let launched = LockIsolated<[(ExternalDiffSettings, DiffTarget)]>([]) let store = withDependencies { $0.settingsFileStorage = storage.storage $0.settingsFileURL = settingsFileURL $0.terminalClient.send = { _ in } - $0.externalDiffToolClient.open = { settings, worktree, _, _ in - launched.withValue { $0.append((settings, worktree)) } + $0.externalDiffToolClient.open = { settings, target, _, _ in + launched.withValue { $0.append((settings, target)) } } } operation: { @Shared(.settingsFile) var settingsFile @@ -987,7 +987,93 @@ struct AppFeatureCommandPaletteTests { ) ] ) - #expect(launched.value.map(\.1) == [worktree]) + #expect(launched.value.map(\.1) == [DiffTarget(worktree: worktree)]) + } + + @Test(.dependencies) func showDiffForWorkspaceChildTargetsChildRepository() async { + let entry = ProjectWorkspace.RepositoryEntry( + id: "app", + name: "App", + path: "app", + sourceKind: .existingPath + ) + let workspace = Repository( + id: "/tmp/ws-child-diff", + rootURL: URL(fileURLWithPath: "/tmp/ws-child-diff"), + name: "Workspace", + kind: .plain, + worktrees: [], + workspace: ProjectWorkspace(title: "Workspace", repositories: [entry]) + ) + let childID = entry.resolvedURL(relativeTo: workspace.rootURL).path(percentEncoded: false) + var repositoriesState = RepositoriesFeature.State() + repositoriesState.repositories = [workspace] + repositoriesState.selection = .repository(workspace.id) + repositoriesState.selectedWorkspaceChildID = childID + repositoriesState.workspaceChildBranchByID[childID] = "feature/child" + + let launched = LockIsolated<[DiffTarget]>([]) + let store = TestStore( + initialState: AppFeature.State( + repositories: repositoriesState, + settings: SettingsFeature.State() + ) + ) { + AppFeature() + } withDependencies: { + $0.externalDiffToolClient.open = { _, target, _, _ in + launched.withValue { $0.append(target) } + } + } + store.exhaustivity = .off + + // Badge/context-menu route and selection-following shortcut route must + // resolve to the same child target. + await store.send(.repositories(.delegate(.showDiff(.workspaceChild(childID))))) + await store.send(.showSelectedWorktreeDiff) + await store.finish() + + let childURL = URL(fileURLWithPath: childID) + #expect(launched.value.count == 2) + for target in launched.value { + #expect(target.id == .workspaceChild(childID)) + #expect(target.workingDirectory == childURL) + #expect(target.branchName == "feature/child") + #expect(target.repositoryRootURL == childURL) + #expect(target.terminalHost.id == workspace.id) + #expect(target.terminalWorkingDirectory == childURL) + } + } + + @Test func commandPaletteOffersDiffItemsForSelectedWorkspaceChild() { + let entry = ProjectWorkspace.RepositoryEntry( + id: "app", + name: "App", + path: "app", + sourceKind: .existingPath + ) + let workspace = Repository( + id: "/tmp/ws-palette-diff", + rootURL: URL(fileURLWithPath: "/tmp/ws-palette-diff"), + name: "Workspace", + kind: .plain, + worktrees: [], + workspace: ProjectWorkspace(title: "Workspace", repositories: [entry]) + ) + let childID = entry.resolvedURL(relativeTo: workspace.rootURL).path(percentEncoded: false) + var repositoriesState = RepositoriesFeature.State() + repositoriesState.repositories = [workspace] + repositoriesState.selection = .repository(workspace.id) + + // Workspace selected without a child: no diff target, no diff items. + let itemsWithoutChild = CommandPaletteFeature.commandPaletteItems(from: repositoriesState) + #expect(!itemsWithoutChild.contains { $0.kind == .showDiff }) + #expect(!itemsWithoutChild.contains { $0.kind == .outgoingChanges }) + + repositoriesState.selectedWorkspaceChildID = childID + let items = CommandPaletteFeature.commandPaletteItems(from: repositoriesState) + #expect(items.contains { $0.kind == .showDiff }) + #expect(items.contains { $0.kind == .outgoingChanges }) } @Test(.dependencies) func outgoingChangesAlwaysUsesBuiltInClient() async { @@ -1025,7 +1111,7 @@ struct AppFeatureCommandPaletteTests { #expect( CommandPaletteFeature.commandPaletteItems(from: repositoriesState).contains { $0.kind == .outgoingChanges } ) - let outgoingRequests = LockIsolated<[Worktree]>([]) + let outgoingRequests = LockIsolated<[DiffTarget]>([]) let externalRequests = LockIsolated(0) let store = TestStore( initialState: AppFeature.State( @@ -1035,8 +1121,8 @@ struct AppFeatureCommandPaletteTests { ) { AppFeature() } withDependencies: { - $0.outgoingChangesClient.open = { worktree, _, _ in - outgoingRequests.withValue { $0.append(worktree) } + $0.outgoingChangesClient.open = { target, _, _ in + outgoingRequests.withValue { $0.append(target) } } $0.externalDiffToolClient.open = { _, _, _, _ in externalRequests.withValue { $0 += 1 } @@ -1048,7 +1134,7 @@ struct AppFeatureCommandPaletteTests { await store.finish() let requests = outgoingRequests.value - #expect(requests == [worktree, worktree]) + #expect(requests == [DiffTarget(worktree: worktree), DiffTarget(worktree: worktree)]) #expect(externalRequests.value == 0) } @@ -1062,7 +1148,7 @@ struct AppFeatureCommandPaletteTests { var repositoriesState = RepositoriesFeature.State() repositoriesState.repositories = [repository] repositoriesState.selection = .worktree(worktree.id) - let outgoingRequests = LockIsolated<[Worktree]>([]) + let outgoingRequests = LockIsolated<[DiffTarget]>([]) let store = TestStore( initialState: AppFeature.State( repositories: repositoriesState, @@ -1071,15 +1157,15 @@ struct AppFeatureCommandPaletteTests { ) { AppFeature() } withDependencies: { - $0.outgoingChangesClient.open = { worktree, _, _ in - outgoingRequests.withValue { $0.append(worktree) } + $0.outgoingChangesClient.open = { target, _, _ in + outgoingRequests.withValue { $0.append(target) } } } await store.send(.showSelectedWorktreeOutgoingChanges) await store.finish() - #expect(outgoingRequests.value == [worktree]) + #expect(outgoingRequests.value == [DiffTarget(worktree: worktree)]) #expect(store.state.alert == nil) } @@ -1133,7 +1219,7 @@ struct AppFeatureCommandPaletteTests { var repositoriesState = RepositoriesFeature.State() repositoriesState.repositories = [repository] repositoriesState.selection = .worktree(selected.id) - let outgoingRequests = LockIsolated<[Worktree]>([]) + let outgoingRequests = LockIsolated<[DiffTarget]>([]) let store = TestStore( initialState: AppFeature.State( repositories: repositoriesState, @@ -1142,16 +1228,16 @@ struct AppFeatureCommandPaletteTests { ) { AppFeature() } withDependencies: { - $0.outgoingChangesClient.open = { worktree, _, _ in - outgoingRequests.withValue { $0.append(worktree) } + $0.outgoingChangesClient.open = { target, _, _ in + outgoingRequests.withValue { $0.append(target) } } } store.exhaustivity = .off - await store.send(.repositories(.delegate(.showOutgoingChanges(targeted.id)))) + await store.send(.repositories(.delegate(.showOutgoingChanges(.worktree(targeted.id))))) await store.finish() - #expect(outgoingRequests.value == [targeted]) + #expect(outgoingRequests.value == [DiffTarget(worktree: targeted)]) } @Test(.dependencies) func closePullRequestDispatchesAction() async { diff --git a/supacodeTests/ExternalDiffToolTests.swift b/supacodeTests/ExternalDiffToolTests.swift index e8b11bda..4ff9f76c 100644 --- a/supacodeTests/ExternalDiffToolTests.swift +++ b/supacodeTests/ExternalDiffToolTests.swift @@ -72,7 +72,7 @@ struct ExternalDiffToolTests { } operation: { await ExternalDiffToolClient.liveValue.open( ExternalDiffSettings(toolID: ExternalDiffTool.hunk.settingsID, customCommand: ""), - worktree, + DiffTarget(worktree: worktree), .appDefaults ) { _ in } } @@ -91,6 +91,52 @@ struct ExternalDiffToolTests { ) } + @Test func hunkForWorkspaceChildRunsInWorkspaceTerminalWithChildCwd() async { + let sentCommands = LockIsolated<[TerminalClient.Command]>([]) + let workspaceWorktree = Worktree( + id: "/tmp/workspace", + name: "Workspace", + detail: "/tmp/workspace", + workingDirectory: URL(fileURLWithPath: "/tmp/workspace"), + repositoryRootURL: URL(fileURLWithPath: "/tmp/workspace") + ) + let childURL = URL(fileURLWithPath: "/tmp/workspace/app") + let target = DiffTarget( + id: .workspaceChild(childURL.path(percentEncoded: false)), + workingDirectory: childURL, + branchName: "feature", + repositoryRootURL: childURL, + terminalHost: workspaceWorktree, + terminalWorkingDirectory: childURL + ) + + await withDependencies { + $0.terminalClient.send = { command in + sentCommands.withValue { $0.append(command) } + } + } operation: { + await ExternalDiffToolClient.liveValue.open( + ExternalDiffSettings(toolID: ExternalDiffTool.hunk.settingsID, customCommand: ""), + target, + .appDefaults + ) { _ in } + } + + #expect( + sentCommands.value == [ + .createTabWithInput( + workspaceWorktree, + input: "hunk diff", + workingDirectory: childURL, + runSetupScriptIfNew: false, + autoCloseOnSuccess: false, + customCommandName: "Hunk Diff · app", + customCommandIcon: "square.split.2x1" + ) + ] + ) + } + @Test func customCommandRunsRenderedShellCommandInWorktree() async throws { let runs = LockIsolated<[ShellRun]>([]) let worktree = Worktree( @@ -119,7 +165,7 @@ struct ExternalDiffToolTests { toolID: ExternalDiffTool.custom.settingsID, customCommand: "my-diff {leftPath} {rightPath} --repo {repoPath}" ), - worktree, + DiffTarget(worktree: worktree), .appDefaults ) { _ in } } @@ -144,15 +190,7 @@ struct ExternalDiffToolTests { try "two\n".write(to: repoURL.appending(path: "tracked.txt"), atomically: true, encoding: .utf8) try "new\n".write(to: repoURL.appending(path: "untracked.txt"), atomically: true, encoding: .utf8) - let worktree = Worktree( - id: repoURL.path(percentEncoded: false), - name: "main", - detail: "main", - workingDirectory: repoURL, - repositoryRootURL: repoURL - ) - - let snapshot = try await ExternalDiffSnapshotClient.liveValue.makeSnapshotPair(worktree) + let snapshot = try await ExternalDiffSnapshotClient.liveValue.makeSnapshotPair(repoURL) #expect(try String(contentsOf: snapshot.leftURL.appending(path: "tracked.txt"), encoding: .utf8) == "one\n") #expect(try String(contentsOf: snapshot.rightURL.appending(path: "tracked.txt"), encoding: .utf8) == "two\n") diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 86be46b2..1b69ad7c 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -7705,6 +7705,100 @@ struct RepositoriesFeatureTests { #expect(rows.first?.info == nil) } + @Test func diffTargetForWorkspaceChildDiffsChildRepoAndHostsHunkInWorkspace() { + let entry = ProjectWorkspace.RepositoryEntry( + id: "app", + name: "App", + path: "app", + sourceKind: .existingPath, + branchName: "metadata-branch" + ) + let repository = makeWorkspaceRepository(id: "/tmp/ws-diff", children: [entry]) + var state = makeState(repositories: [repository]) + let childID = entry.resolvedURL(relativeTo: repository.rootURL).path(percentEncoded: false) + state.workspaceChildBranchByID[childID] = "live-branch" + + let target = state.diffTarget(for: .workspaceChild(childID)) + + let childURL = URL(fileURLWithPath: childID) + #expect(target?.id == .workspaceChild(childID)) + #expect(target?.workingDirectory == childURL) + // Live branch wins over the metadata branch. + #expect(target?.branchName == "live-branch") + #expect(target?.repositoryRootURL == childURL) + // Hunk stays hosted in the workspace terminal, running in the child dir. + #expect(target?.terminalHost.id == repository.id) + #expect(target?.terminalHost.workingDirectory == repository.rootURL) + #expect(target?.terminalWorkingDirectory == childURL) + } + + @Test func diffTargetForWorkspaceChildFallsBackBranchToMetadataThenRepositoryName() { + let withMetadataBranch = ProjectWorkspace.RepositoryEntry( + id: "app", + name: "App", + path: "app", + sourceKind: .existingPath, + branchName: "metadata-branch" + ) + let withoutAnyBranch = ProjectWorkspace.RepositoryEntry( + id: "api", + name: "Api", + path: "api", + sourceKind: .existingPath + ) + let repository = makeWorkspaceRepository( + id: "/tmp/ws-diff-fallback", + children: [withMetadataBranch, withoutAnyBranch] + ) + let state = makeState(repositories: [repository]) + let metadataChildID = withMetadataBranch.resolvedURL(relativeTo: repository.rootURL) + .path(percentEncoded: false) + let namelessChildID = withoutAnyBranch.resolvedURL(relativeTo: repository.rootURL) + .path(percentEncoded: false) + + #expect(state.diffTarget(for: .workspaceChild(metadataChildID))?.branchName == "metadata-branch") + #expect(state.diffTarget(for: .workspaceChild(namelessChildID))?.branchName == "Api") + } + + @Test func diffTargetResolvesWorktreesAndRejectsUnknownChildren() { + let worktree = makeWorktree(id: "/tmp/repo-dt/wt", name: "feature") + let repository = makeRepository(id: "/tmp/repo-dt", worktrees: [worktree]) + let state = makeState(repositories: [repository]) + + #expect(state.diffTarget(for: .worktree(worktree.id)) == DiffTarget(worktree: worktree)) + #expect(state.diffTarget(for: .worktree("/tmp/missing")) == nil) + #expect(state.diffTarget(for: .workspaceChild("/tmp/missing")) == nil) + } + + @Test func selectedDiffTargetIDFollowsWorktreeThenWorkspaceChild() { + let entry = ProjectWorkspace.RepositoryEntry( + id: "app", + name: "App", + path: "app", + sourceKind: .existingPath + ) + let workspace = makeWorkspaceRepository(id: "/tmp/ws-selected", children: [entry]) + let worktree = makeWorktree(id: "/tmp/repo-sel/wt", name: "alpha") + let repository = makeRepository(id: "/tmp/repo-sel", worktrees: [worktree]) + var state = makeState(repositories: [repository, workspace]) + let childID = entry.resolvedURL(relativeTo: workspace.rootURL).path(percentEncoded: false) + + state.selection = .worktree(worktree.id) + #expect(state.selectedDiffTargetID == .worktree(worktree.id)) + + state.selection = .repository(workspace.id) + state.selectedWorkspaceChildID = childID + #expect(state.selectedDiffTargetID == .workspaceChild(childID)) + + // A stale child id must not leak once a non-workspace repository is selected. + state.selection = .repository(repository.id) + #expect(state.selectedDiffTargetID == nil) + + state.selection = .repository(workspace.id) + state.selectedWorkspaceChildID = nil + #expect(state.selectedDiffTargetID == nil) + } + @Test func openWorkspaceChildFocusesOrCreatesBoundTerminalTabInChildDirectory() async { let entry = ProjectWorkspace.RepositoryEntry( id: "app", -- 2.51.2