Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/components/canvas.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/components/command-palette.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions docs/components/github-pull-requests.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion supacode/App/supacodeApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
32 changes: 22 additions & 10 deletions supacode/Commands/WorktreeCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import SwiftUI

struct WorktreeCommands: Commands {
@Bindable var store: StoreOf<AppFeature>
let terminalManager: WorktreeTerminalManager
@FocusedValue(\.openSelectedWorktreeAction) private var openSelectedWorktreeAction
@FocusedValue(\.confirmWorktreeAction) private var confirmWorktreeAction
@FocusedValue(\.archiveWorktreeAction) private var archiveWorktreeAction
Expand All @@ -12,8 +13,9 @@ struct WorktreeCommands: Commands {
@FocusedValue(\.stopRunScriptAction) private var stopRunScriptAction
@FocusedValue(\.visibleHotkeyWorktreeRows) private var visibleHotkeyWorktreeRows

init(store: StoreOf<AppFeature>) {
init(store: StoreOf<AppFeature>, terminalManager: WorktreeTerminalManager) {
self.store = store
self.terminalManager = terminalManager
}

var body: some Commands {
Expand Down Expand Up @@ -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? {
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -639,26 +644,27 @@ 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,
pullRequest.state.uppercased() != "CLOSED"
{
return pullRequestItems(
pullRequest: pullRequest,
worktreeID: selectedWorktreeID,
worktreeID: worktreeID,
repositoryID: repositoryID,
codeHost: codeHost
)
Expand All @@ -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
Expand Down
91 changes: 73 additions & 18 deletions supacode/Features/Repositories/Views/WorktreeDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<AppFeature>
let terminalManager: WorktreeTerminalManager
@Environment(CommandKeyObserver.self) private var commandKeyObserver
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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]
Expand Down
26 changes: 26 additions & 0 deletions supacodeTests/CommandPaletteFeatureTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
61 changes: 61 additions & 0 deletions supacodeTests/WorktreeCommandsTests.swift
Original file line number Diff line number Diff line change
@@ -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)
)
}
}
Loading