native macOS codings agent orchestrator
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #11 from onevcat/feature/canvas-shortcut

feat: ⌥⌘↩ toggle Canvas with worktree+tab restoration

authored by

Wei Wang and committed by
GitHub
5cdaaf0b fd1db6cf

+96 -25
+4
supacode/App/AppShortcuts.swift
··· 90 90 static let stopRunScript = AppShortcut(key: ".", modifiers: .command) 91 91 static let checkForUpdates = AppShortcut(key: "u", modifiers: .command) 92 92 static let showDiff = AppShortcut(key: "]", modifiers: .command) 93 + static let toggleCanvas = AppShortcut( 94 + keyEquivalent: .return, ghosttyKeyName: "return", modifiers: [.command, .option] 95 + ) 93 96 static let archivedWorktrees = AppShortcut(key: "a", modifiers: [.command, .control]) 94 97 static let selectNextWorktree = AppShortcut( 95 98 keyEquivalent: .downArrow, ghosttyKeyName: "arrow_down", modifiers: [.command, .control] ··· 144 147 stopRunScript, 145 148 checkForUpdates, 146 149 showDiff, 150 + toggleCanvas, 147 151 archivedWorktrees, 148 152 selectNextWorktree, 149 153 selectPreviousWorktree,
+4 -1
supacode/App/supacodeApp.swift
··· 138 138 }, 139 139 events: { 140 140 terminalManager.eventStream() 141 + }, 142 + canvasFocusedWorktreeID: { 143 + terminalManager.canvasFocusedWorktreeID 141 144 } 142 145 ) 143 146 values.worktreeInfoWatcher = WorktreeInfoWatcherClient( ··· 171 174 .environment(commandKeyObserver) 172 175 .commands { 173 176 WorktreeCommands(store: store) 174 - SidebarCommands() 177 + SidebarCommands(store: store) 175 178 TerminalCommands(ghosttyShortcuts: ghosttyShortcuts) 176 179 CommandGroup(after: .textEditing) { 177 180 Button("Command Palette") {
+5 -2
supacode/Clients/Terminal/TerminalClient.swift
··· 4 4 struct TerminalClient { 5 5 var send: @MainActor @Sendable (Command) -> Void 6 6 var events: @MainActor @Sendable () -> AsyncStream<Event> 7 + var canvasFocusedWorktreeID: @MainActor @Sendable () -> Worktree.ID? 7 8 8 9 enum Command: Equatable { 9 10 case createTab(Worktree, runSetupScriptIfNew: Bool) ··· 41 42 extension TerminalClient: DependencyKey { 42 43 static let liveValue = TerminalClient( 43 44 send: { _ in fatalError("TerminalClient.send not configured") }, 44 - events: { fatalError("TerminalClient.events not configured") } 45 + events: { fatalError("TerminalClient.events not configured") }, 46 + canvasFocusedWorktreeID: { nil } 45 47 ) 46 48 47 49 static let testValue = TerminalClient( 48 50 send: { _ in }, 49 - events: { AsyncStream { $0.finish() } } 51 + events: { AsyncStream { $0.finish() } }, 52 + canvasFocusedWorktreeID: { nil } 50 53 ) 51 54 } 52 55
+27
supacode/Commands/SidebarCommands.swift
··· 1 + import ComposableArchitecture 1 2 import SwiftUI 2 3 3 4 struct SidebarCommands: Commands { 5 + @Bindable var store: StoreOf<AppFeature> 4 6 @FocusedValue(\.toggleLeftSidebarAction) private var toggleLeftSidebarAction 5 7 6 8 var body: some Commands { ··· 13 15 ) 14 16 .help("Toggle Left Sidebar (\(AppShortcuts.toggleLeftSidebar.display))") 15 17 .disabled(toggleLeftSidebarAction == nil) 18 + Divider() 19 + Button("Canvas") { 20 + store.send(.repositories(.toggleCanvas)) 21 + } 22 + .keyboardShortcut( 23 + AppShortcuts.toggleCanvas.keyEquivalent, 24 + modifiers: AppShortcuts.toggleCanvas.modifiers 25 + ) 26 + .help("Canvas (\(AppShortcuts.toggleCanvas.display))") 27 + Button("Show Diff") { 28 + let repos = store.repositories 29 + guard let worktreeID = repos.selectedWorktreeID, 30 + let worktree = repos.worktree(for: worktreeID) 31 + else { return } 32 + DiffWindowManager.shared.show( 33 + worktreeURL: worktree.workingDirectory, 34 + branchName: worktree.name, 35 + ) 36 + } 37 + .keyboardShortcut( 38 + AppShortcuts.showDiff.keyEquivalent, 39 + modifiers: AppShortcuts.showDiff.modifiers 40 + ) 41 + .help("Show Diff (\(AppShortcuts.showDiff.display))") 42 + .disabled(store.repositories.selectedWorktreeID == nil) 16 43 } 17 44 } 18 45 }
-16
supacode/Commands/WorktreeCommands.swift
··· 121 121 .keyboardShortcut(.return, modifiers: .command) 122 122 .help("Confirm Worktree Action (⌘↩)") 123 123 .disabled(confirmWorktreeAction == nil) 124 - Button("Show Diff") { 125 - let repos = store.repositories 126 - guard let worktreeID = repos.selectedWorktreeID, 127 - let worktree = repos.worktree(for: worktreeID) 128 - else { return } 129 - DiffWindowManager.shared.show( 130 - worktreeURL: worktree.workingDirectory, 131 - branchName: worktree.name, 132 - ) 133 - } 134 - .keyboardShortcut( 135 - AppShortcuts.showDiff.keyEquivalent, 136 - modifiers: AppShortcuts.showDiff.modifiers 137 - ) 138 - .help("Show Diff (\(AppShortcuts.showDiff.display))") 139 - .disabled(!hasActiveWorktree) 140 124 Button("Refresh Worktrees") { 141 125 store.send(.repositories(.refreshWorktrees)) 142 126 }
+10 -4
supacode/Features/Canvas/Views/CanvasSidebarButton.swift
··· 4 4 struct CanvasSidebarButton: View { 5 5 let store: StoreOf<RepositoriesFeature> 6 6 let isSelected: Bool 7 + @Environment(CommandKeyObserver.self) private var commandKeyObserver 7 8 8 9 var body: some View { 9 10 Button { 10 11 store.send(.selectCanvas) 11 12 } label: { 12 - Label("Canvas", systemImage: "square.grid.2x2") 13 - .font(.callout) 14 - .frame(maxWidth: .infinity) 13 + HStack(spacing: 6) { 14 + Label("Canvas", systemImage: "square.grid.2x2") 15 + .font(.callout) 16 + .frame(maxWidth: .infinity, alignment: .leading) 17 + if commandKeyObserver.isPressed { 18 + ShortcutHintView(text: AppShortcuts.toggleCanvas.display, color: .secondary) 19 + } 20 + } 15 21 } 16 22 .buttonStyle(.plain) 17 23 .padding(.horizontal, 12) 18 24 .padding(.vertical, 6) 19 25 .background(isSelected ? Color.accentColor.opacity(0.15) : .clear, in: .rect(cornerRadius: 6)) 20 - .help("Canvas") 26 + .help("Canvas (\(AppShortcuts.toggleCanvas.display))") 21 27 } 22 28 }
+21 -2
supacode/Features/Canvas/Views/CanvasView.swift
··· 401 401 let previousTabID = focusedTabID 402 402 focusedTabID = tabID 403 403 404 + // Sync the tab selection on the owning worktree so that exiting canvas 405 + // (via toggleCanvas → selectWorktree) will focus the correct tab. 406 + if let ownerState = states.first(where: { $0.surfaceView(for: tabID) != nil }) { 407 + ownerState.tabManager.selectTab(tabID) 408 + terminalManager.canvasFocusedWorktreeID = ownerState.worktreeID 409 + } 410 + 404 411 // Unfocus all surfaces in the previous card's split tree 405 412 if let previousTabID, previousTabID != tabID, 406 413 let previousState = states.first(where: { $0.surfaceView(for: previousTabID) != nil }) ··· 430 437 431 438 private func activateCanvas() { 432 439 cleanStaleLayouts() 433 - for state in terminalManager.activeWorktreeStates { 440 + 441 + let activeStates = terminalManager.activeWorktreeStates 442 + 443 + // Auto-focus the card that was active before entering canvas. 444 + if let selectedID = terminalManager.selectedWorktreeID, 445 + let state = activeStates.first(where: { $0.worktreeID == selectedID }), 446 + let tabID = state.tabManager.selectedTabId, 447 + let surface = state.surfaceView(for: tabID) 448 + { 449 + focusCard(tabID, surfaceView: surface, states: activeStates) 450 + } 451 + 452 + for state in activeStates { 434 453 state.setAllSurfacesOccluded() 435 454 } 436 455 // Un-occlude all surfaces visible on canvas (including split panes) 437 - for state in terminalManager.activeWorktreeStates { 456 + for state in activeStates { 438 457 for tab in state.tabManager.tabs { 439 458 for surface in state.splitTree(for: tab.id).leaves() { 440 459 surface.setOcclusion(true)
+22
supacode/Features/Repositories/Reducer/RepositoriesFeature.swift
··· 83 83 var automaticallyArchiveMergedWorktrees = false 84 84 var moveNotifiedWorktreeToTop = true 85 85 var lastFocusedWorktreeID: Worktree.ID? 86 + var preCanvasWorktreeID: Worktree.ID? 86 87 var shouldRestoreLastFocusedWorktree = false 87 88 var shouldSelectFirstAfterReload = false 88 89 var isRefreshingWorktrees = false ··· 134 135 case repositoriesLoaded([Repository], failures: [LoadFailure], roots: [URL], animated: Bool) 135 136 case selectArchivedWorktrees 136 137 case selectCanvas 138 + case toggleCanvas 137 139 case setSidebarSelectedWorktreeIDs(Set<Worktree.ID>) 138 140 case openRepositories([URL]) 139 141 case openRepositoriesFinished( ··· 292 294 case worktreeCreated(Worktree) 293 295 } 294 296 297 + @Dependency(TerminalClient.self) private var terminalClient 295 298 @Dependency(AnalyticsClient.self) private var analyticsClient 296 299 @Dependency(GitClientDependency.self) private var gitClient 297 300 @Dependency(GithubCLIClient.self) private var githubCLI ··· 546 549 return .send(.delegate(.selectedWorktreeChanged(nil))) 547 550 548 551 case .selectCanvas: 552 + // Remember the current worktree so toggleCanvas can restore it. 553 + state.preCanvasWorktreeID = state.selectedWorktreeID 549 554 state.selection = .canvas 550 555 state.sidebarSelectedWorktreeIDs = [] 551 556 return .none 557 + 558 + case .toggleCanvas: 559 + if state.isShowingCanvas { 560 + // Exit canvas: prefer the card focused in canvas, then the worktree 561 + // we came from, then the first available worktree. 562 + let targetID = 563 + terminalClient.canvasFocusedWorktreeID() 564 + ?? state.preCanvasWorktreeID 565 + ?? state.lastFocusedWorktreeID 566 + ?? state.orderedWorktreeRows().first?.id 567 + guard let targetID else { return .none } 568 + return .send(.selectWorktree(targetID, focusTerminal: true)) 569 + } else { 570 + // Enter canvas if there are any open worktrees. 571 + guard !state.orderedWorktreeRows().isEmpty else { return .none } 572 + return .send(.selectCanvas) 573 + } 552 574 553 575 case .setSidebarSelectedWorktreeIDs(let worktreeIDs): 554 576 let validWorktreeIDs = Set(state.orderedWorktreeRows().map(\.id))
+3
supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift
··· 13 13 private var eventContinuation: AsyncStream<TerminalClient.Event>.Continuation? 14 14 private var pendingEvents: [TerminalClient.Event] = [] 15 15 var selectedWorktreeID: Worktree.ID? 16 + /// The worktree+tab focused in Canvas, updated by CanvasView on card tap. 17 + /// Used by toggleCanvas to know which worktree to return to. 18 + var canvasFocusedWorktreeID: Worktree.ID? 16 19 17 20 init(runtime: GhosttyRuntime) { 18 21 self.runtime = runtime