diff --git a/docs/components/canvas.md b/docs/components/canvas.md index 9352e20c..6fdce076 100644 --- a/docs/components/canvas.md +++ b/docs/components/canvas.md @@ -71,6 +71,14 @@ These also appear as toolbar buttons. There's a `?` help popover (bottom-left) explaining pan/zoom/expand — hover the button to reveal it, move away to dismiss, or click to pin it open. +## Toolbar actions + +The main toolbar tracks the focused card. Its center status area shows the same +PR/check summary, toast, or time hint as Normal view, and the right-side Run +Script / Custom Command buttons run against the focused card's worktree. `⌘⌃G` +(`open_pull_request`, "Open on Code Host") opens that card's PR when it has one, +or the repository page otherwise. + ## Visual cues - **Focused card:** bright accent border. **Selected (multi-select):** medium diff --git a/docs/components/command-palette.md b/docs/components/command-palette.md index f8d70a18..9334cb00 100644 --- a/docs/components/command-palette.md +++ b/docs/components/command-palette.md @@ -52,6 +52,7 @@ selected worktree has a pull request). suggestions. - PR and Canvas entries appear/disappear as state changes (PR present, Canvas active, etc.). +- In Canvas, worktree-scoped actions use the focused card as their context. - There are no user settings for the palette; ranking is automatic. ## Gotchas for agents diff --git a/docs/components/github-pull-requests.md b/docs/components/github-pull-requests.md index a1c4081c..79bcb164 100644 --- a/docs/components/github-pull-requests.md +++ b/docs/components/github-pull-requests.md @@ -43,10 +43,11 @@ palette. ## Actions (via Command Palette, when a PR exists) -Open the [Command Palette](command-palette.md) (`⌘P`) on a worktree that has a PR: +Open the [Command Palette](command-palette.md) (`⌘P`) on a worktree that has a PR +(or focus that worktree's card in Canvas): - **Open Pull Request on GitHub** — open it in the browser. (`⌘⌃G` "Open on Code - Host" also opens the PR/repo page.) + Host" also opens the PR/repo page, including from Canvas.) - **Mark PR Ready for Review** — convert a draft to ready (only when it's a draft). - **Copy failing job URL** — copy the first failing check's URL. - **Copy CI Failure Logs** — extract and copy the failed run's logs (great to hand diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index 1c37663b..cf6a63d5 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -801,7 +801,7 @@ struct SupacodeApp: App { // Grouped to keep `commands` under SwiftUI's CommandsBuilder // tuple-arity limit when `#if DEBUG` adds the Debug menu below. Group { - WorktreeCommands(store: store) + WorktreeCommands(store: store, terminalManager: terminalManager) SidebarCommands(store: store) TerminalCommands( ghosttyShortcuts: ghosttyShortcuts, diff --git a/supacode/Commands/WorktreeCommands.swift b/supacode/Commands/WorktreeCommands.swift index c1c191c7..d26efe2f 100644 --- a/supacode/Commands/WorktreeCommands.swift +++ b/supacode/Commands/WorktreeCommands.swift @@ -4,6 +4,7 @@ import SwiftUI struct WorktreeCommands: Commands { @Bindable var store: StoreOf + let terminalManager: WorktreeTerminalManager @FocusedValue(\.openSelectedWorktreeAction) private var openSelectedWorktreeAction @FocusedValue(\.confirmWorktreeAction) private var confirmWorktreeAction @FocusedValue(\.archiveWorktreeAction) private var archiveWorktreeAction @@ -12,8 +13,9 @@ struct WorktreeCommands: Commands { @FocusedValue(\.stopRunScriptAction) private var stopRunScriptAction @FocusedValue(\.visibleHotkeyWorktreeRows) private var visibleHotkeyWorktreeRows - init(store: StoreOf) { + init(store: StoreOf, terminalManager: WorktreeTerminalManager) { self.store = store + self.terminalManager = terminalManager } var body: some Commands { @@ -159,15 +161,10 @@ struct WorktreeCommands: Commands { } private var selectedCodeHostWorktreeID: Worktree.ID? { - let repositories = store.repositories - guard let selectedWorktreeID = repositories.selectedWorktreeID else { return nil } - guard - let repositoryID = repositories.repositoryID(containing: selectedWorktreeID), - repositories.repositories[id: repositoryID]?.capabilities.supportsCodeHost == true - else { - return nil - } - return selectedWorktreeID + codeHostWorktreeID( + repositories: store.repositories, + canvasFocusedWorktreeID: store.repositories.isShowingCanvas ? terminalManager.canvasFocusedWorktreeID : nil + ) } private func keyboardShortcut(for commandID: String) -> KeyboardShortcut? { @@ -280,6 +277,21 @@ struct WorktreeCommands: Commands { } } +func codeHostWorktreeID( + repositories: RepositoriesFeature.State, + canvasFocusedWorktreeID: Worktree.ID? +) -> Worktree.ID? { + let candidateID = repositories.selectedWorktreeID ?? canvasFocusedWorktreeID + guard + let candidateID, + let repositoryID = repositories.repositoryID(containing: candidateID), + repositories.repositories[id: repositoryID]?.capabilities.supportsCodeHost == true + else { + return nil + } + return candidateID +} + private struct WorktreeMenuEntry: Identifiable { enum Kind { case worktree(WorktreeRowModel) diff --git a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift index 3b4fa274..b9d75004 100644 --- a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift +++ b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift @@ -291,7 +291,12 @@ struct CommandPaletteFeature { ) ) } - items.append(contentsOf: selectedCodeHostItems(from: repositories)) + items.append( + contentsOf: selectedCodeHostItems( + from: repositories, + actionTargetWorktreeID: worktreeActionTargetID + ) + ) #if DEBUG items.append(contentsOf: debugToastItems()) #endif @@ -639,18 +644,19 @@ private func canvasCommandItems() -> [CommandPaletteItem] { } private func selectedCodeHostItems( - from repositories: RepositoriesFeature.State + from repositories: RepositoriesFeature.State, + actionTargetWorktreeID: Worktree.ID? = nil ) -> [CommandPaletteItem] { guard - let selectedWorktreeID = repositories.selectedWorktreeID, - let repositoryID = repositories.repositoryID(containing: selectedWorktreeID), + let worktreeID = actionTargetWorktreeID ?? repositories.selectedWorktreeID, + let repositoryID = repositories.repositoryID(containing: worktreeID), let repository = repositories.repositories[id: repositoryID] else { return [] } let codeHost = repositories.codeHost(for: repositoryID) - let pullRequest = repositories.worktreeInfo(for: selectedWorktreeID)?.pullRequest + let pullRequest = repositories.worktreeInfo(for: worktreeID)?.pullRequest if repository.capabilities.supportsPullRequests, let pullRequest, pullRequest.number > 0, @@ -658,7 +664,7 @@ private func selectedCodeHostItems( { return pullRequestItems( pullRequest: pullRequest, - worktreeID: selectedWorktreeID, + worktreeID: worktreeID, repositoryID: repositoryID, codeHost: codeHost ) @@ -673,7 +679,7 @@ private func selectedCodeHostItems( id: CommandPaletteItemID.pullRequestOpen(repositoryID), title: "Open Repository on \(codeHost.displayName)", subtitle: repository.name, - kind: .openRepositoryOnCodeHost(selectedWorktreeID), + kind: .openRepositoryOnCodeHost(worktreeID), category: .pullRequest, defaultSuggestion: false, priorityTier: 2 diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index abfb218e..f5157b6f 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -23,6 +23,9 @@ struct WorktreeDetailView: View { } private struct CanvasToolbarState { + let statusToast: RepositoriesFeature.StatusToast? + let pullRequest: GithubPullRequest? + let codeHost: CodeHost let notificationGroups: [ToolbarNotificationRepositoryGroup] let unseenNotificationWorktreeCount: Int let runScriptEnabled: Bool @@ -34,6 +37,16 @@ struct WorktreeDetailView: View { let showRunButtonInToolbar: Bool } + private struct CanvasToolbarStateInput { + let appState: AppFeature.State + let actionTargetWorktree: Worktree? + let notificationGroups: [ToolbarNotificationRepositoryGroup] + let unseenNotificationWorktreeCount: Int + let runScriptEnabled: Bool + let runScriptIsRunning: Bool + let customCommands: [UserCustomCommand] + } + @Bindable var store: StoreOf let terminalManager: WorktreeTerminalManager @Environment(CommandKeyObserver.self) private var commandKeyObserver @@ -91,16 +104,16 @@ struct WorktreeDetailView: View { .toolbar { if repositories.isShowingCanvas { canvasToolbarContent( - state: CanvasToolbarState( - notificationGroups: notificationGroups, - unseenNotificationWorktreeCount: unseenNotificationWorktreeCount, - runScriptEnabled: runScriptEnabled, - runScriptIsRunning: runScriptIsRunning, - customCommands: customCommands, - isUpdateAvailable: state.updates.isUpdateAvailable, - isUpdateReadyToInstall: state.updates.isUpdateReadyToInstall, - availableUpdateVersion: state.updates.availableVersion, - showRunButtonInToolbar: settingsFile.global.showRunButtonInToolbar + state: canvasToolbarState( + input: CanvasToolbarStateInput( + appState: state, + actionTargetWorktree: actionTargetWorktree, + notificationGroups: notificationGroups, + unseenNotificationWorktreeCount: unseenNotificationWorktreeCount, + runScriptEnabled: runScriptEnabled, + runScriptIsRunning: runScriptIsRunning, + customCommands: customCommands + ) ) ) } else if hasActiveTerminalTarget, @@ -197,10 +210,41 @@ struct WorktreeDetailView: View { ) } + private func canvasToolbarState( + input: CanvasToolbarStateInput + ) -> CanvasToolbarState { + CanvasToolbarState( + statusToast: input.appState.repositories.statusToast, + pullRequest: matchedPullRequest( + for: input.actionTargetWorktree, + repositories: input.appState.repositories + ), + codeHost: input.appState.repositories.codeHost(forWorktreeID: input.actionTargetWorktree?.id), + notificationGroups: input.notificationGroups, + unseenNotificationWorktreeCount: input.unseenNotificationWorktreeCount, + runScriptEnabled: input.runScriptEnabled, + runScriptIsRunning: input.runScriptIsRunning, + customCommands: input.customCommands, + isUpdateAvailable: input.appState.updates.isUpdateAvailable, + isUpdateReadyToInstall: input.appState.updates.isUpdateReadyToInstall, + availableUpdateVersion: input.appState.updates.availableVersion, + showRunButtonInToolbar: settingsFile.global.showRunButtonInToolbar + ) + } + @ToolbarContentBuilder private func canvasToolbarContent( state: CanvasToolbarState ) -> some ToolbarContent { + ToolbarItem(placement: .principal) { + ToolbarStatusView( + toast: state.statusToast, + pullRequest: state.pullRequest, + codeHost: state.codeHost + ) + .padding(.horizontal) + } + ToolbarItemGroup(placement: .primaryAction) { ToolbarNotificationsPopoverButton( groups: state.notificationGroups, @@ -312,17 +356,13 @@ struct WorktreeDetailView: View { else { return nil } - let pullRequest = input.selectedWorktree.flatMap { input.repositories.worktreeInfo(for: $0.id)?.pullRequest } - let matchesBranch = - if let selectedWorktree = input.selectedWorktree, let pullRequest { - pullRequest.headRefName == nil || pullRequest.headRefName == selectedWorktree.name - } else { - false - } return WorktreeToolbarState( title: title, statusToast: input.repositories.statusToast, - pullRequest: matchesBranch ? pullRequest : nil, + pullRequest: matchedPullRequest( + for: input.selectedWorktree, + repositories: input.repositories + ), codeHost: input.repositories.codeHost(forWorktreeID: input.selectedWorktree?.id), notificationGroups: input.notificationGroups, unseenNotificationWorktreeCount: input.unseenNotificationWorktreeCount, @@ -363,6 +403,21 @@ struct WorktreeDetailView: View { } } + private func matchedPullRequest( + for worktree: Worktree?, + repositories: RepositoriesFeature.State + ) -> GithubPullRequest? { + guard let worktree, + let pullRequest = repositories.worktreeInfo(for: worktree.id)?.pullRequest + else { + return nil + } + guard pullRequest.headRefName == nil || pullRequest.headRefName == worktree.name else { + return nil + } + return pullRequest + } + private func shouldShowMultiSelectionSummary( repositories: RepositoriesFeature.State, selectedWorktreeSummaries: [MultiSelectedWorktreeSummary] diff --git a/supacodeTests/CommandPaletteFeatureTests.swift b/supacodeTests/CommandPaletteFeatureTests.swift index 9f734cfb..f1a48bde 100644 --- a/supacodeTests/CommandPaletteFeatureTests.swift +++ b/supacodeTests/CommandPaletteFeatureTests.swift @@ -557,6 +557,32 @@ struct CommandPaletteFeatureTests { #expect(githubOpenItem?.title == "Open Repository on GitHub") } + @Test func commandPaletteItems_showsCodeHostActionForCanvasActionTarget() { + let rootPath = "/tmp/repo-canvas-code-host" + let worktree = makeWorktree(id: "\(rootPath)/wt-1", name: "feature/canvas", repoRoot: rootPath) + let repository = makeRepository(rootPath: rootPath, name: "Repo", worktrees: [worktree]) + var state = RepositoriesFeature.State(repositories: [repository]) + state.selection = .canvas + state.codeHostByRepositoryID[repository.id] = .github + state.worktreeInfoByID[worktree.id] = WorktreeInfoEntry( + pullRequest: makePullRequest() + ) + + let items = CommandPaletteFeature.commandPaletteItems( + from: state, + actionTargetWorktreeID: worktree.id + ) + let openItem = items.first { + if case .openPullRequest(let worktreeID) = $0.kind { + return worktreeID == worktree.id + } + return false + } + + #expect(openItem?.title == "Open Pull Request on GitHub") + #expect(openItem?.subtitle == "PR") + } + @Test func emptyQueryHidesChangeFocusedTabIcon() { let rootPath = "/tmp/repo" let worktree = makeWorktree(id: rootPath, name: "repo", repoRoot: rootPath) diff --git a/supacodeTests/WorktreeCommandsTests.swift b/supacodeTests/WorktreeCommandsTests.swift new file mode 100644 index 00000000..bb7419b7 --- /dev/null +++ b/supacodeTests/WorktreeCommandsTests.swift @@ -0,0 +1,61 @@ +import Foundation +import IdentifiedCollections +import Testing + +@testable import supacode + +@MainActor +struct WorktreeCommandsTests { + @Test func codeHostWorktreeIDUsesCanvasFocusedWorktreeInCanvasMode() { + let rootPath = "/tmp/repo-canvas-command-code-host" + let worktree = Self.makeWorktree(id: "\(rootPath)/wt-1", name: "feature/canvas", repoRoot: rootPath) + let repository = Self.makeRepository(rootPath: rootPath, name: "Repo", worktrees: [worktree]) + var state = RepositoriesFeature.State(repositories: [repository]) + state.selection = SidebarSelection.canvas + + let result = codeHostWorktreeID( + repositories: state, + canvasFocusedWorktreeID: worktree.id + ) + + #expect(result == worktree.id) + } + + @Test func codeHostWorktreeIDRequiresCodeHostSupport() { + let repository = Repository( + id: "/tmp/plain-folder-code-host", + rootURL: URL(fileURLWithPath: "/tmp/plain-folder-code-host"), + name: "Folder", + kind: .plain, + worktrees: [] + ) + var state = RepositoriesFeature.State(repositories: [repository]) + state.selection = SidebarSelection.canvas + + let result = codeHostWorktreeID( + repositories: state, + canvasFocusedWorktreeID: repository.id + ) + + #expect(result == nil) + } + + private static func makeWorktree(id: String, name: String, repoRoot: String) -> Worktree { + Worktree( + id: id, + name: name, + detail: id, + workingDirectory: URL(fileURLWithPath: id), + repositoryRootURL: URL(fileURLWithPath: repoRoot) + ) + } + + private static func makeRepository(rootPath: String, name: String, worktrees: [Worktree]) -> Repository { + Repository( + id: rootPath, + rootURL: URL(fileURLWithPath: rootPath), + name: name, + worktrees: IdentifiedArray(uniqueElements: worktrees) + ) + } +}