native macOS codings agent orchestrator
6
fork

Configure Feed

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

Merge pull request #188 from onevcat/feat/issue-180-reveal-in-sidebar

Add Reveal in Sidebar command

authored by

Wei Wang and committed by
GitHub
306f618e f1cc1a90

+281 -113
+9
supacode/App/AppShortcuts.swift
··· 99 99 static let checkForUpdates = "check_for_updates" 100 100 static let showDiff = "show_diff" 101 101 static let toggleCanvas = "toggle_canvas" 102 + static let revealInSidebar = "reveal_in_sidebar" 102 103 static let archivedWorktrees = "archived_worktrees" 103 104 static let selectNextWorktree = "select_next_worktree" 104 105 static let selectPreviousWorktree = "select_previous_worktree" ··· 174 175 static let toggleCanvas = AppShortcut( 175 176 keyEquivalent: .return, ghosttyKeyName: "return", modifiers: [.command, .option] 176 177 ) 178 + static let revealInSidebar = AppShortcut(key: "l", modifiers: [.command, .shift]) 177 179 static let archivedWorktrees = AppShortcut(key: "a", modifiers: [.command, .control]) 178 180 static let selectNextWorktree = AppShortcut( 179 181 keyEquivalent: .downArrow, ghosttyKeyName: "arrow_down", modifiers: [.command, .control] ··· 371 373 title: "Toggle Canvas", 372 374 scope: .configurableAppAction, 373 375 shortcut: toggleCanvas 376 + ), 377 + .init( 378 + id: CommandID.revealInSidebar, 379 + title: "Reveal in Sidebar", 380 + scope: .configurableAppAction, 381 + shortcut: revealInSidebar 374 382 ), 375 383 .init( 376 384 id: CommandID.archivedWorktrees, ··· 705 713 openRepository, 706 714 openPullRequest, 707 715 toggleLeftSidebar, 716 + revealInSidebar, 708 717 refreshWorktrees, 709 718 runScript, 710 719 stopRunScript,
+11
supacode/App/ContentView.swift
··· 88 88 ) 89 89 } 90 90 .focusedSceneValue(\.toggleLeftSidebarAction, toggleLeftSidebar) 91 + .focusedSceneValue(\.revealInSidebarAction, revealInSidebarAction) 91 92 .overlay { 92 93 CommandPaletteOverlayView( 93 94 store: store.scope(state: \.commandPalette, action: \.commandPalette), ··· 104 105 private func toggleLeftSidebar() { 105 106 withAnimation(.easeOut(duration: 0.2)) { 106 107 leftSidebarVisibility = leftSidebarVisibility == .detailOnly ? .all : .detailOnly 108 + } 109 + } 110 + 111 + private var revealInSidebarAction: (() -> Void)? { 112 + guard store.repositories.selectedWorktreeID != nil else { return nil } 113 + return { 114 + withAnimation(.easeOut(duration: 0.2)) { 115 + leftSidebarVisibility = .all 116 + } 117 + store.send(.repositories(.revealSelectedWorktreeInSidebar)) 107 118 } 108 119 } 109 120
+16
supacode/Commands/SidebarCommands.swift
··· 4 4 struct SidebarCommands: Commands { 5 5 @Bindable var store: StoreOf<AppFeature> 6 6 @FocusedValue(\.toggleLeftSidebarAction) private var toggleLeftSidebarAction 7 + @FocusedValue(\.revealInSidebarAction) private var revealInSidebarAction 7 8 8 9 var body: some Commands { 9 10 CommandGroup(replacing: .sidebar) { ··· 13 14 .modifier(KeyboardShortcutModifier(shortcut: keyboardShortcut(for: AppShortcuts.CommandID.toggleLeftSidebar))) 14 15 .help(helpText(title: "Toggle Left Sidebar", commandID: AppShortcuts.CommandID.toggleLeftSidebar)) 15 16 .disabled(toggleLeftSidebarAction == nil) 17 + Button("Reveal in Sidebar") { 18 + revealInSidebarAction?() 19 + } 20 + .modifier(KeyboardShortcutModifier(shortcut: keyboardShortcut(for: AppShortcuts.CommandID.revealInSidebar))) 21 + .help(helpText(title: "Reveal in Sidebar", commandID: AppShortcuts.CommandID.revealInSidebar)) 22 + .disabled(revealInSidebarAction == nil) 16 23 Divider() 17 24 Button("Canvas") { 18 25 store.send(.repositories(.toggleCanvas)) ··· 52 59 typealias Value = () -> Void 53 60 } 54 61 62 + private struct RevealInSidebarActionKey: FocusedValueKey { 63 + typealias Value = () -> Void 64 + } 65 + 55 66 extension FocusedValues { 56 67 var toggleLeftSidebarAction: (() -> Void)? { 57 68 get { self[ToggleLeftSidebarActionKey.self] } 58 69 set { self[ToggleLeftSidebarActionKey.self] = newValue } 70 + } 71 + 72 + var revealInSidebarAction: (() -> Void)? { 73 + get { self[RevealInSidebarActionKey.self] } 74 + set { self[RevealInSidebarActionKey.self] = newValue } 59 75 } 60 76 }
+28
supacode/Features/Repositories/Reducer/RepositoriesFeature.swift
··· 44 44 } 45 45 } 46 46 47 + struct PendingSidebarReveal: Equatable, Sendable { 48 + let id: Int 49 + let worktreeID: Worktree.ID 50 + } 51 + 47 52 @Reducer 48 53 struct RepositoriesFeature { 49 54 enum CancelID { ··· 210 215 var inFlightPullRequestRefreshRepositoryIDs: Set<Repository.ID> = [] 211 216 var queuedPullRequestRefreshByRepositoryID: [Repository.ID: PendingPullRequestRefresh] = [:] 212 217 var sidebarSelectedWorktreeIDs: Set<Worktree.ID> = [] 218 + var nextPendingSidebarRevealID = 0 219 + var pendingSidebarReveal: PendingSidebarReveal? 213 220 @Shared(.appStorage("sidebarCollapsedRepositoryIDs")) var collapsedRepositoryIDs: [Repository.ID] = [] 214 221 @Presents var worktreeCreationPrompt: WorktreeCreationPromptFeature.State? 215 222 @Presents var alert: AlertState<Alert>? ··· 266 273 case selectWorktree(Worktree.ID?, focusTerminal: Bool = false) 267 274 case selectNextWorktree 268 275 case selectPreviousWorktree 276 + case revealSelectedWorktreeInSidebar 277 + case consumePendingSidebarReveal(Int) 269 278 case requestRenameBranch(Worktree.ID, String) 270 279 case presentAlert(title: String, message: String) 271 280 case worktreeInfoEvent(WorktreeInfoWatcherClient.Event) ··· 652 661 case .selectPreviousWorktree: 653 662 guard let id = state.worktreeID(byOffset: -1) else { return .none } 654 663 return .send(.selectWorktree(id)) 664 + 665 + case .revealSelectedWorktreeInSidebar: 666 + guard let worktreeID = state.selectedWorktreeID, 667 + let repositoryID = state.repositoryID(containing: worktreeID) 668 + else { return .none } 669 + state.$collapsedRepositoryIDs.withLock { 670 + $0.removeAll { $0 == repositoryID } 671 + } 672 + state.nextPendingSidebarRevealID += 1 673 + state.pendingSidebarReveal = .init( 674 + id: state.nextPendingSidebarRevealID, 675 + worktreeID: worktreeID 676 + ) 677 + return .none 678 + 679 + case .consumePendingSidebarReveal(let revealID): 680 + guard state.pendingSidebarReveal?.id == revealID else { return .none } 681 + state.pendingSidebarReveal = nil 682 + return .none 655 683 656 684 case .requestRenameBranch(let worktreeID, let branchName): 657 685 guard let worktree = state.worktree(for: worktreeID) else { return .none }
+138 -113
supacode/Features/Repositories/Views/SidebarListView.swift
··· 6 6 @Binding var expandedRepoIDs: Set<Repository.ID> 7 7 @Binding var sidebarSelections: Set<SidebarSelection> 8 8 let terminalManager: WorktreeTerminalManager 9 + @FocusState private var isSidebarFocused: Bool 9 10 @State private var isDragActive = false 10 11 11 12 var body: some View { ··· 87 88 } 88 89 ) 89 90 let repositoriesByID = Dictionary(uniqueKeysWithValues: store.repositories.map { ($0.id, $0) }) 90 - List(selection: selection) { 91 - if orderedRoots.isEmpty { 92 - let repositories = store.repositories 93 - ForEach(Array(repositories.enumerated()), id: \.element.id) { index, repository in 94 - RepositorySectionView( 95 - repository: repository, 96 - hasTopSpacing: index > 0, 97 - isDragActive: isDragActive, 98 - hotkeyRows: hotkeyRows, 99 - selectedWorktreeIDs: selectedWorktreeIDs, 100 - expandedRepoIDs: $expandedRepoIDs, 101 - store: store, 102 - terminalManager: terminalManager 103 - ) 104 - .listRowInsets(EdgeInsets()) 105 - } 106 - } else { 107 - let orderedRows = Array(orderedRoots.enumerated()).map { index, rootURL in 108 - ( 109 - index: index, 110 - rootURL: rootURL, 111 - repositoryID: rootURL.standardizedFileURL.path(percentEncoded: false) 112 - ) 113 - } 114 - ForEach(orderedRows, id: \.repositoryID) { row in 115 - let index = row.index 116 - let rootURL = row.rootURL 117 - let repositoryID = row.repositoryID 118 - if let failureMessage = state.loadFailuresByID[repositoryID] { 119 - let name = Repository.name(for: rootURL.standardizedFileURL) 120 - let path = rootURL.standardizedFileURL.path(percentEncoded: false) 121 - FailedRepositoryRow( 122 - name: name, 123 - path: path, 124 - showFailure: { 125 - let message = "\(path)\n\n\(failureMessage)" 126 - store.send(.presentAlert(title: "Unable to load \(name)", message: message)) 127 - }, 128 - removeRepository: { 129 - store.send(.repositoryManagement(.removeFailedRepository(repositoryID))) 130 - } 131 - ) 132 - .padding(.horizontal, 12) 133 - .overlay(alignment: .top) { 134 - if index > 0 { 135 - Rectangle() 136 - .fill(.secondary) 137 - .frame(height: 1) 138 - .frame(maxWidth: .infinity) 139 - .accessibilityHidden(true) 140 - } 141 - } 142 - .listRowInsets(EdgeInsets()) 143 - } else if let repository = repositoriesByID[repositoryID] { 91 + let pendingSidebarReveal = state.pendingSidebarReveal 92 + 93 + ScrollViewReader { scrollProxy in 94 + List(selection: selection) { 95 + if orderedRoots.isEmpty { 96 + let repositories = store.repositories 97 + ForEach(Array(repositories.enumerated()), id: \.element.id) { index, repository in 144 98 RepositorySectionView( 145 99 repository: repository, 146 100 hasTopSpacing: index > 0, ··· 153 107 ) 154 108 .listRowInsets(EdgeInsets()) 155 109 } 110 + } else { 111 + let orderedRows = Array(orderedRoots.enumerated()).map { index, rootURL in 112 + ( 113 + index: index, 114 + rootURL: rootURL, 115 + repositoryID: rootURL.standardizedFileURL.path(percentEncoded: false) 116 + ) 117 + } 118 + ForEach(orderedRows, id: \.repositoryID) { row in 119 + let index = row.index 120 + let rootURL = row.rootURL 121 + let repositoryID = row.repositoryID 122 + if let failureMessage = state.loadFailuresByID[repositoryID] { 123 + let name = Repository.name(for: rootURL.standardizedFileURL) 124 + let path = rootURL.standardizedFileURL.path(percentEncoded: false) 125 + FailedRepositoryRow( 126 + name: name, 127 + path: path, 128 + showFailure: { 129 + let message = "\(path)\n\n\(failureMessage)" 130 + store.send(.presentAlert(title: "Unable to load \(name)", message: message)) 131 + }, 132 + removeRepository: { 133 + store.send(.repositoryManagement(.removeFailedRepository(repositoryID))) 134 + } 135 + ) 136 + .padding(.horizontal, 12) 137 + .overlay(alignment: .top) { 138 + if index > 0 { 139 + Rectangle() 140 + .fill(.secondary) 141 + .frame(height: 1) 142 + .frame(maxWidth: .infinity) 143 + .accessibilityHidden(true) 144 + } 145 + } 146 + .listRowInsets(EdgeInsets()) 147 + } else if let repository = repositoriesByID[repositoryID] { 148 + RepositorySectionView( 149 + repository: repository, 150 + hasTopSpacing: index > 0, 151 + isDragActive: isDragActive, 152 + hotkeyRows: hotkeyRows, 153 + selectedWorktreeIDs: selectedWorktreeIDs, 154 + expandedRepoIDs: $expandedRepoIDs, 155 + store: store, 156 + terminalManager: terminalManager 157 + ) 158 + .listRowInsets(EdgeInsets()) 159 + } 160 + } 161 + .onMove { offsets, destination in 162 + store.send(.worktreeOrdering(.repositoriesMoved(offsets, destination))) 163 + } 156 164 } 157 - .onMove { offsets, destination in 158 - store.send(.worktreeOrdering(.repositoriesMoved(offsets, destination))) 165 + } 166 + .listStyle(.sidebar) 167 + .scrollIndicators(.never) 168 + .frame(minWidth: 220) 169 + .onDragSessionUpdated { session in 170 + if case .ended = session.phase { 171 + if isDragActive { 172 + isDragActive = false 173 + } 174 + return 175 + } 176 + if case .dataTransferCompleted = session.phase { 177 + if isDragActive { 178 + isDragActive = false 179 + } 180 + return 181 + } 182 + if !isDragActive { 183 + isDragActive = true 159 184 } 160 185 } 161 - } 162 - .listStyle(.sidebar) 163 - .scrollIndicators(.never) 164 - .frame(minWidth: 220) 165 - .onDragSessionUpdated { session in 166 - if case .ended = session.phase { 167 - if isDragActive { 168 - isDragActive = false 186 + .safeAreaInset(edge: .top) { 187 + CanvasSidebarButton( 188 + store: store, 189 + isSelected: state.isShowingCanvas 190 + ) 191 + .padding(.top, 4) 192 + .background(.bar) 193 + .overlay(alignment: .bottom) { 194 + Divider() 169 195 } 170 - return 196 + } 197 + .safeAreaInset(edge: .bottom) { 198 + SidebarFooterView(store: store) 171 199 } 172 - if case .dataTransferCompleted = session.phase { 173 - if isDragActive { 174 - isDragActive = false 175 - } 176 - return 200 + .dropDestination(for: URL.self) { urls, _ in 201 + let fileURLs = urls.filter(\.isFileURL) 202 + guard !fileURLs.isEmpty else { return false } 203 + store.send(.repositoryManagement(.openRepositories(fileURLs))) 204 + return true 177 205 } 178 - if !isDragActive { 179 - isDragActive = true 206 + .onKeyPress { keyPress in 207 + guard !keyPress.characters.isEmpty else { return .ignored } 208 + let isNavigationKey = 209 + keyPress.key == .upArrow 210 + || keyPress.key == .downArrow 211 + || keyPress.key == .leftArrow 212 + || keyPress.key == .rightArrow 213 + || keyPress.key == .home 214 + || keyPress.key == .end 215 + || keyPress.key == .pageUp 216 + || keyPress.key == .pageDown 217 + if isNavigationKey { return .ignored } 218 + let hasCommandModifier = keyPress.modifiers.contains(.command) 219 + if hasCommandModifier { return .ignored } 220 + guard let worktreeID = store.selectedWorktreeID, 221 + state.sidebarSelectedWorktreeIDs.count == 1, 222 + state.sidebarSelectedWorktreeIDs.contains(worktreeID), 223 + let terminalState = terminalManager.stateIfExists(for: worktreeID) 224 + else { return .ignored } 225 + terminalState.focusAndInsertText(keyPress.characters) 226 + return .handled 180 227 } 181 - } 182 - .safeAreaInset(edge: .top) { 183 - CanvasSidebarButton( 184 - store: store, 185 - isSelected: state.isShowingCanvas 186 - ) 187 - .padding(.top, 4) 188 - .background(.bar) 189 - .overlay(alignment: .bottom) { 190 - Divider() 228 + .focused($isSidebarFocused) 229 + .task(id: pendingSidebarReveal?.id) { 230 + await revealPendingSidebarWorktree(pendingSidebarReveal, with: scrollProxy) 191 231 } 192 - } 193 - .safeAreaInset(edge: .bottom) { 194 - SidebarFooterView(store: store) 195 - } 196 - .dropDestination(for: URL.self) { urls, _ in 197 - let fileURLs = urls.filter(\.isFileURL) 198 - guard !fileURLs.isEmpty else { return false } 199 - store.send(.repositoryManagement(.openRepositories(fileURLs))) 200 - return true 201 - } 202 - .onKeyPress { keyPress in 203 - guard !keyPress.characters.isEmpty else { return .ignored } 204 - let isNavigationKey = 205 - keyPress.key == .upArrow 206 - || keyPress.key == .downArrow 207 - || keyPress.key == .leftArrow 208 - || keyPress.key == .rightArrow 209 - || keyPress.key == .home 210 - || keyPress.key == .end 211 - || keyPress.key == .pageUp 212 - || keyPress.key == .pageDown 213 - if isNavigationKey { return .ignored } 214 - let hasCommandModifier = keyPress.modifiers.contains(.command) 215 - if hasCommandModifier { return .ignored } 216 - guard let worktreeID = store.selectedWorktreeID, 217 - state.sidebarSelectedWorktreeIDs.count == 1, 218 - state.sidebarSelectedWorktreeIDs.contains(worktreeID), 219 - let terminalState = terminalManager.stateIfExists(for: worktreeID) 220 - else { return .ignored } 221 - terminalState.focusAndInsertText(keyPress.characters) 222 - return .handled 232 + } // ScrollViewReader 233 + } 234 + 235 + @MainActor 236 + private func revealPendingSidebarWorktree( 237 + _ pendingSidebarReveal: PendingSidebarReveal?, 238 + with scrollProxy: ScrollViewProxy 239 + ) async { 240 + guard let pendingSidebarReveal else { return } 241 + // Give SwiftUI time to materialize newly expanded section rows before scrolling. 242 + await Task.yield() 243 + await Task.yield() 244 + isSidebarFocused = true 245 + withAnimation(.easeOut(duration: 0.2)) { 246 + scrollProxy.scrollTo(pendingSidebarReveal.worktreeID, anchor: .center) 223 247 } 248 + store.send(.consumePendingSidebarReveal(pendingSidebarReveal.id)) 224 249 } 225 250 } 226 251
+1
supacode/Features/Repositories/Views/WorktreeRowsView.swift
··· 227 227 onStopRunScript: config.onStopRunScript, 228 228 ) 229 229 .tag(SidebarSelection.worktree(row.id)) 230 + .id(row.id) 230 231 .typeSelectEquivalent("") 231 232 .listRowInsets(EdgeInsets()) 232 233 .listRowSeparator(.hidden)
+78
supacodeTests/RepositoriesFeatureTests.swift
··· 472 472 #expect(savedEntries.value == expectedSavedEntries) 473 473 } 474 474 475 + @Test func revealInSidebarExpandsCollapsedRepository() async { 476 + let worktree = makeWorktree(id: "/tmp/repo/wt", name: "wt") 477 + let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) 478 + var initialState = makeState(repositories: [repository]) 479 + initialState.selection = .worktree(worktree.id) 480 + initialState.sidebarSelectedWorktreeIDs = [worktree.id] 481 + initialState.$collapsedRepositoryIDs.withLock { $0 = [repository.id] } 482 + let store = TestStore(initialState: initialState) { 483 + RepositoriesFeature() 484 + } 485 + 486 + await store.send(.revealSelectedWorktreeInSidebar) { 487 + $0.$collapsedRepositoryIDs.withLock { $0 = [] } 488 + $0.nextPendingSidebarRevealID = 1 489 + $0.pendingSidebarReveal = .init(id: 1, worktreeID: worktree.id) 490 + } 491 + } 492 + 493 + @Test func revealInSidebarWithNoSelectionIsNoOp() async { 494 + let worktree = makeWorktree(id: "/tmp/repo/wt", name: "wt") 495 + let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) 496 + let initialState = makeState(repositories: [repository]) 497 + let store = TestStore(initialState: initialState) { 498 + RepositoriesFeature() 499 + } 500 + 501 + await store.send(.revealSelectedWorktreeInSidebar) 502 + } 503 + 504 + @Test func revealInSidebarKeepsOtherRepositoriesCollapsed() async { 505 + let worktree1 = makeWorktree(id: "/tmp/repo-a/wt", name: "wt", repoRoot: "/tmp/repo-a") 506 + let worktree2 = makeWorktree(id: "/tmp/repo-b/wt", name: "wt", repoRoot: "/tmp/repo-b") 507 + let repoA = makeRepository(id: "/tmp/repo-a", worktrees: [worktree1]) 508 + let repoB = makeRepository(id: "/tmp/repo-b", worktrees: [worktree2]) 509 + var initialState = makeState(repositories: [repoA, repoB]) 510 + initialState.selection = .worktree(worktree1.id) 511 + initialState.sidebarSelectedWorktreeIDs = [worktree1.id] 512 + initialState.$collapsedRepositoryIDs.withLock { $0 = [repoA.id, repoB.id] } 513 + let store = TestStore(initialState: initialState) { 514 + RepositoriesFeature() 515 + } 516 + 517 + await store.send(.revealSelectedWorktreeInSidebar) { 518 + $0.$collapsedRepositoryIDs.withLock { $0 = [repoB.id] } 519 + $0.nextPendingSidebarRevealID = 1 520 + $0.pendingSidebarReveal = .init(id: 1, worktreeID: worktree1.id) 521 + } 522 + } 523 + 524 + @Test func consumePendingSidebarRevealClearsMatchingRequest() async { 525 + let worktree = makeWorktree(id: "/tmp/repo/wt", name: "wt") 526 + let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) 527 + var initialState = makeState(repositories: [repository]) 528 + initialState.nextPendingSidebarRevealID = 1 529 + initialState.pendingSidebarReveal = .init(id: 1, worktreeID: worktree.id) 530 + let store = TestStore(initialState: initialState) { 531 + RepositoriesFeature() 532 + } 533 + 534 + await store.send(.consumePendingSidebarReveal(1)) { 535 + $0.pendingSidebarReveal = nil 536 + } 537 + } 538 + 539 + @Test func consumePendingSidebarRevealIgnoresStaleRequest() async { 540 + let worktree = makeWorktree(id: "/tmp/repo/wt", name: "wt") 541 + let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) 542 + var initialState = makeState(repositories: [repository]) 543 + initialState.nextPendingSidebarRevealID = 2 544 + initialState.pendingSidebarReveal = .init(id: 2, worktreeID: worktree.id) 545 + let store = TestStore(initialState: initialState) { 546 + RepositoriesFeature() 547 + } 548 + 549 + // Stale ID should be ignored, pendingSidebarReveal remains unchanged. 550 + await store.send(.consumePendingSidebarReveal(1)) 551 + } 552 + 475 553 @Test func openRepositoriesDoesNotDowngradeFoldersOnUnexpectedProbeError() async { 476 554 let repoSelection = "/tmp/repo/subdir" 477 555 let repoRoot = "/tmp/repo"