native macOS codings agent orchestrator
6
fork

Configure Feed

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

Merge pull request #185 from supabitapp/sbertix/reveal-in-wortkree

authored by

khoi and committed by
GitHub
ac59c4b0 89ef4462

+208 -57
+6 -2
supacode/App/AppShortcuts.swift
··· 6 6 // Compile-time checkable shortcut identifier. 7 7 nonisolated enum AppShortcutID: Codable, Hashable, Sendable, CodingKeyRepresentable { 8 8 case commandPalette, openSettings, checkForUpdates 9 - case toggleLeftSidebar 9 + case toggleLeftSidebar, revealInSidebar 10 10 case newWorktree, refreshWorktrees, archivedWorktrees, archiveWorktree 11 11 case deleteWorktree, confirmWorktreeAction 12 12 case selectNextWorktree, selectPreviousWorktree ··· 37 37 case .openSettings: "openSettings" 38 38 case .checkForUpdates: "checkForUpdates" 39 39 case .toggleLeftSidebar: "toggleLeftSidebar" 40 + case .revealInSidebar: "revealInSidebar" 40 41 case .newWorktree: "newWorktree" 41 42 case .refreshWorktrees: "refreshWorktrees" 42 43 case .archivedWorktrees: "archivedWorktrees" ··· 60 61 "openSettings": .openSettings, 61 62 "checkForUpdates": .checkForUpdates, 62 63 "toggleLeftSidebar": .toggleLeftSidebar, 64 + "revealInSidebar": .revealInSidebar, 63 65 "newWorktree": .newWorktree, 64 66 "refreshWorktrees": .refreshWorktrees, 65 67 "archivedWorktrees": .archivedWorktrees, ··· 94 96 case .openSettings: "Open Settings" 95 97 case .checkForUpdates: "Check For Updates" 96 98 case .toggleLeftSidebar: "Toggle Left Sidebar" 99 + case .revealInSidebar: "Reveal in Sidebar" 97 100 case .newWorktree: "New Worktree" 98 101 case .refreshWorktrees: "Refresh Worktrees" 99 102 case .archivedWorktrees: "Archived Worktrees" ··· 265 268 static let checkForUpdates = AppShortcut(id: .checkForUpdates, key: "u", modifiers: .command) 266 269 267 270 static let toggleLeftSidebar = AppShortcut(id: .toggleLeftSidebar, key: "[", modifiers: .command) 271 + static let revealInSidebar = AppShortcut(id: .revealInSidebar, key: "e", modifiers: [.command, .shift]) 268 272 269 273 static let newWorktree = AppShortcut(id: .newWorktree, key: "n", modifiers: .command) 270 274 static let refreshWorktrees = AppShortcut(id: .refreshWorktrees, key: "r", modifiers: [.command, .shift]) ··· 317 321 318 322 static let groups: [AppShortcutGroup] = [ 319 323 AppShortcutGroup(category: .general, shortcuts: [commandPalette, openSettings, checkForUpdates]), 320 - AppShortcutGroup(category: .sidebar, shortcuts: [toggleLeftSidebar]), 324 + AppShortcutGroup(category: .sidebar, shortcuts: [toggleLeftSidebar, revealInSidebar]), 321 325 AppShortcutGroup( 322 326 category: .worktrees, 323 327 shortcuts: [
+13
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 withAnimation(.easeOut(duration: 0.2)) { 105 106 leftSidebarVisibility = leftSidebarVisibility == .detailOnly ? .all : .detailOnly 106 107 } 108 + } 109 + 110 + private var revealInSidebarAction: (() -> Void)? { 111 + guard store.repositories.selectedWorktreeID != nil else { return nil } 112 + return { revealInSidebar() } 113 + } 114 + 115 + private func revealInSidebar() { 116 + withAnimation(.easeOut(duration: 0.2)) { 117 + leftSidebarVisibility = .all 118 + } 119 + store.send(.repositories(.revealSelectedWorktreeInSidebar)) 107 120 } 108 121 109 122 }
+19 -1
supacode/Commands/SidebarCommands.swift
··· 3 3 4 4 struct SidebarCommands: Commands { 5 5 @FocusedValue(\.toggleLeftSidebarAction) private var toggleLeftSidebarAction 6 + @FocusedValue(\.revealInSidebarAction) private var revealInSidebarAction 6 7 @Shared(.settingsFile) private var settingsFile 7 8 @Shared(.appStorage("worktreeRowDisplayMode")) private var displayMode: WorktreeRowDisplayMode = .branchFirst 8 9 @Shared(.appStorage("worktreeRowHideSubtitleOnMatch")) private var hideSubtitleOnMatch = true 9 10 10 11 var body: some Commands { 11 - let toggleLeftSidebar = AppShortcuts.toggleLeftSidebar.effective(from: settingsFile.global.shortcutOverrides) 12 + let overrides = settingsFile.global.shortcutOverrides 13 + let toggleLeftSidebar = AppShortcuts.toggleLeftSidebar.effective(from: overrides) 14 + let revealInSidebar = AppShortcuts.revealInSidebar.effective(from: overrides) 12 15 CommandGroup(replacing: .sidebar) { 13 16 Button("Toggle Left Sidebar", systemImage: "sidebar.leading") { 14 17 toggleLeftSidebarAction?() ··· 16 19 .appKeyboardShortcut(toggleLeftSidebar) 17 20 .help("Toggle Left Sidebar (\(toggleLeftSidebar?.display ?? "none"))") 18 21 .disabled(toggleLeftSidebarAction == nil) 22 + Button("Reveal in Sidebar") { 23 + revealInSidebarAction?() 24 + } 25 + .appKeyboardShortcut(revealInSidebar) 26 + .help("Reveal in Sidebar (\(revealInSidebar?.display ?? "none"))") 27 + .disabled(revealInSidebarAction == nil) 19 28 Section { 20 29 Picker("Title and Subtitle", systemImage: "textformat", selection: Binding($displayMode)) { 21 30 ForEach(WorktreeRowDisplayMode.allCases) { mode in ··· 32 41 typealias Value = () -> Void 33 42 } 34 43 44 + private struct RevealInSidebarActionKey: FocusedValueKey { 45 + typealias Value = () -> Void 46 + } 47 + 35 48 extension FocusedValues { 36 49 var toggleLeftSidebarAction: (() -> Void)? { 37 50 get { self[ToggleLeftSidebarActionKey.self] } 38 51 set { self[ToggleLeftSidebarActionKey.self] = newValue } 52 + } 53 + 54 + var revealInSidebarAction: (() -> Void)? { 55 + get { self[RevealInSidebarActionKey.self] } 56 + set { self[RevealInSidebarActionKey.self] = newValue } 39 57 } 40 58 }
+25
supacode/Features/Repositories/Reducer/RepositoriesFeature.swift
··· 57 57 58 58 @Reducer 59 59 struct RepositoriesFeature { 60 + struct PendingSidebarReveal: Equatable { 61 + let id: Int 62 + let worktreeID: Worktree.ID 63 + } 64 + 60 65 @ObservableState 61 66 struct State: Equatable { 62 67 var repositories: IdentifiedArrayOf<Repository> = [] ··· 90 95 var inFlightPullRequestRefreshRepositoryIDs: Set<Repository.ID> = [] 91 96 var queuedPullRequestRefreshByRepositoryID: [Repository.ID: PendingPullRequestRefresh] = [:] 92 97 var sidebarSelectedWorktreeIDs: Set<Worktree.ID> = [] 98 + var nextPendingSidebarRevealID = 0 99 + var pendingSidebarReveal: PendingSidebarReveal? 93 100 @Shared(.appStorage("sidebarCollapsedRepositoryIDs")) var collapsedRepositoryIDs: [Repository.ID] = [] 94 101 @Presents var worktreeCreationPrompt: WorktreeCreationPromptFeature.State? 95 102 @Presents var alert: AlertState<Alert>? ··· 144 151 case selectWorktree(Worktree.ID?, focusTerminal: Bool = false) 145 152 case selectNextWorktree 146 153 case selectPreviousWorktree 154 + case revealSelectedWorktreeInSidebar 155 + case consumePendingSidebarReveal(Int) 147 156 case requestRenameBranch(Worktree.ID, String) 148 157 case createRandomWorktree 149 158 case createRandomWorktreeInRepository(Repository.ID) ··· 590 599 case .selectPreviousWorktree: 591 600 guard let id = state.worktreeID(byOffset: -1) else { return .none } 592 601 return .send(.selectWorktree(id)) 602 + 603 + case .revealSelectedWorktreeInSidebar: 604 + guard let worktreeID = state.selectedWorktreeID, 605 + let repositoryID = state.repositoryID(containing: worktreeID) 606 + else { return .none } 607 + state.$collapsedRepositoryIDs.withLock { 608 + $0.removeAll { $0 == repositoryID } 609 + } 610 + state.nextPendingSidebarRevealID += 1 611 + state.pendingSidebarReveal = .init(id: state.nextPendingSidebarRevealID, worktreeID: worktreeID) 612 + return .none 613 + 614 + case .consumePendingSidebarReveal(let pendingSidebarRevealID): 615 + guard state.pendingSidebarReveal?.id == pendingSidebarRevealID else { return .none } 616 + state.pendingSidebarReveal = nil 617 + return .none 593 618 594 619 case .requestRenameBranch(let worktreeID, let branchName): 595 620 guard let worktree = state.worktree(for: worktreeID) else { return .none }
+79 -54
supacode/Features/Repositories/Views/SidebarListView.swift
··· 4 4 struct SidebarListView: View { 5 5 @Bindable var store: StoreOf<RepositoriesFeature> 6 6 let terminalManager: WorktreeTerminalManager 7 + @FocusState private var isSidebarFocused: Bool 8 + 7 9 var body: some View { 8 10 let state = store.state 9 11 let expandedRepoIDs = state.expandedRepositoryIDs ··· 19 21 } 20 22 ) 21 23 let repositoriesByID = Dictionary(uniqueKeysWithValues: store.repositories.map { ($0.id, $0) }) 22 - List(selection: selection) { 23 - if orderedRoots.isEmpty { 24 - ForEach(store.repositories) { repository in 25 - SidebarRepositorySectionView( 26 - repository: repository, 27 - hotkeyRows: hotkeyRows, 28 - selectedWorktreeIDs: selectedWorktreeIDs, 29 - store: store, 30 - terminalManager: terminalManager 31 - ) 32 - } 33 - } else { 34 - ForEach(sidebarRootRows(from: orderedRoots), id: \.repositoryID) { row in 35 - if let failureMessage = state.loadFailuresByID[row.repositoryID] { 36 - SidebarFailedRepositoryRow( 37 - rootURL: row.rootURL, 38 - failureMessage: failureMessage, 39 - store: store 40 - ) 41 - } else if let repository = repositoriesByID[row.repositoryID] { 24 + let pendingSidebarReveal = state.pendingSidebarReveal 25 + 26 + return ScrollViewReader { scrollProxy in 27 + List(selection: selection) { 28 + if orderedRoots.isEmpty { 29 + ForEach(store.repositories) { repository in 42 30 SidebarRepositorySectionView( 43 31 repository: repository, 44 32 hotkeyRows: hotkeyRows, ··· 47 35 terminalManager: terminalManager 48 36 ) 49 37 } 38 + } else { 39 + ForEach(sidebarRootRows(from: orderedRoots), id: \.repositoryID) { row in 40 + if let failureMessage = state.loadFailuresByID[row.repositoryID] { 41 + SidebarFailedRepositoryRow( 42 + rootURL: row.rootURL, 43 + failureMessage: failureMessage, 44 + store: store 45 + ) 46 + } else if let repository = repositoriesByID[row.repositoryID] { 47 + SidebarRepositorySectionView( 48 + repository: repository, 49 + hotkeyRows: hotkeyRows, 50 + selectedWorktreeIDs: selectedWorktreeIDs, 51 + store: store, 52 + terminalManager: terminalManager 53 + ) 54 + } 55 + } 56 + .onMove { offsets, destination in 57 + store.send(.repositoriesMoved(offsets, destination)) 58 + } 50 59 } 51 - .onMove { offsets, destination in 52 - store.send(.repositoriesMoved(offsets, destination)) 53 - } 60 + } 61 + .listStyle(.sidebar) 62 + .focused($isSidebarFocused) 63 + .frame(minWidth: 220) 64 + .dropDestination(for: URL.self) { urls, _ in 65 + let fileURLs = urls.filter(\.isFileURL) 66 + guard !fileURLs.isEmpty else { return false } 67 + store.send(.openRepositories(fileURLs)) 68 + return true 69 + } 70 + .onKeyPress { keyPress in 71 + guard !keyPress.characters.isEmpty else { return .ignored } 72 + let isNavigationKey = 73 + keyPress.key == .upArrow 74 + || keyPress.key == .downArrow 75 + || keyPress.key == .leftArrow 76 + || keyPress.key == .rightArrow 77 + || keyPress.key == .home 78 + || keyPress.key == .end 79 + || keyPress.key == .pageUp 80 + || keyPress.key == .pageDown 81 + if isNavigationKey { return .ignored } 82 + let hasCommandModifier = keyPress.modifiers.contains(.command) 83 + if hasCommandModifier { return .ignored } 84 + guard let worktreeID = store.selectedWorktreeID, 85 + state.sidebarSelectedWorktreeIDs.count == 1, 86 + state.sidebarSelectedWorktreeIDs.contains(worktreeID), 87 + let terminalState = terminalManager.stateIfExists(for: worktreeID) 88 + else { return .ignored } 89 + terminalState.focusAndInsertText(keyPress.characters) 90 + return .handled 54 91 } 55 - } 56 - .listStyle(.sidebar) 57 - .scrollIndicators(.never) 58 - .frame(minWidth: 220) 59 - .dropDestination(for: URL.self) { urls, _ in 60 - let fileURLs = urls.filter(\.isFileURL) 61 - guard !fileURLs.isEmpty else { return false } 62 - store.send(.openRepositories(fileURLs)) 63 - return true 64 - } 65 - .onKeyPress { keyPress in 66 - guard !keyPress.characters.isEmpty else { return .ignored } 67 - let isNavigationKey = 68 - keyPress.key == .upArrow 69 - || keyPress.key == .downArrow 70 - || keyPress.key == .leftArrow 71 - || keyPress.key == .rightArrow 72 - || keyPress.key == .home 73 - || keyPress.key == .end 74 - || keyPress.key == .pageUp 75 - || keyPress.key == .pageDown 76 - if isNavigationKey { return .ignored } 77 - let hasCommandModifier = keyPress.modifiers.contains(.command) 78 - if hasCommandModifier { return .ignored } 79 - guard let worktreeID = store.selectedWorktreeID, 80 - state.sidebarSelectedWorktreeIDs.count == 1, 81 - state.sidebarSelectedWorktreeIDs.contains(worktreeID), 82 - let terminalState = terminalManager.stateIfExists(for: worktreeID) 83 - else { return .ignored } 84 - terminalState.focusAndInsertText(keyPress.characters) 85 - return .handled 92 + .task(id: pendingSidebarReveal?.id) { 93 + await revealPendingSidebarWorktree(pendingSidebarReveal, with: scrollProxy) 94 + } 86 95 } 87 96 } 88 97 ··· 95 104 repositoryID: rootURL.standardizedFileURL.path(percentEncoded: false) 96 105 ) 97 106 } 107 + } 108 + 109 + @MainActor 110 + private func revealPendingSidebarWorktree( 111 + _ pendingSidebarReveal: RepositoriesFeature.PendingSidebarReveal?, 112 + with scrollProxy: ScrollViewProxy 113 + ) async { 114 + guard let pendingSidebarReveal else { return } 115 + // Give SwiftUI time to materialize newly expanded section rows before scrolling. 116 + await Task.yield() 117 + await Task.yield() 118 + isSidebarFocused = true 119 + withAnimation(.easeOut(duration: 0.2)) { 120 + scrollProxy.scrollTo(pendingSidebarReveal.worktreeID, anchor: .center) 121 + } 122 + store.send(.consumePendingSidebarReveal(pendingSidebarReveal.id)) 98 123 } 99 124 } 100 125
+1
supacode/Features/Repositories/Views/WorktreeRowsView.swift
··· 176 176 } 177 177 } 178 178 .tag(SidebarSelection.worktree(row.id)) 179 + .id(row.id) 179 180 .typeSelectEquivalent("") 180 181 .moveDisabled(moveDisabled) 181 182 .contextMenu {
+65
supacodeTests/RepositoriesFeatureTests.swift
··· 457 457 #expect(primaryRows.count == 2) 458 458 } 459 459 460 + @Test func revealInSidebarExpandsCollapsedRepository() async { 461 + let worktree = makeWorktree(id: "/tmp/repo/wt", name: "wt") 462 + let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) 463 + var initialState = makeState(repositories: [repository]) 464 + initialState.selection = .worktree(worktree.id) 465 + initialState.sidebarSelectedWorktreeIDs = [worktree.id] 466 + initialState.$collapsedRepositoryIDs.withLock { $0 = [repository.id] } 467 + let store = TestStore(initialState: initialState) { 468 + RepositoriesFeature() 469 + } 470 + 471 + await store.send(.revealSelectedWorktreeInSidebar) { 472 + $0.$collapsedRepositoryIDs.withLock { $0 = [] } 473 + $0.nextPendingSidebarRevealID = 1 474 + $0.pendingSidebarReveal = .init(id: 1, worktreeID: worktree.id) 475 + } 476 + } 477 + 478 + @Test func revealInSidebarWithNoSelectionIsNoOp() async { 479 + let worktree = makeWorktree(id: "/tmp/repo/wt", name: "wt") 480 + let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) 481 + let initialState = makeState(repositories: [repository]) 482 + let store = TestStore(initialState: initialState) { 483 + RepositoriesFeature() 484 + } 485 + 486 + await store.send(.revealSelectedWorktreeInSidebar) 487 + } 488 + 489 + @Test func revealInSidebarKeepsOtherRepositoriesCollapsed() async { 490 + let worktree1 = makeWorktree(id: "/tmp/repo-a/wt", name: "wt", repoRoot: "/tmp/repo-a") 491 + let worktree2 = makeWorktree(id: "/tmp/repo-b/wt", name: "wt", repoRoot: "/tmp/repo-b") 492 + let repoA = makeRepository(id: "/tmp/repo-a", worktrees: [worktree1]) 493 + let repoB = makeRepository(id: "/tmp/repo-b", worktrees: [worktree2]) 494 + var initialState = makeState(repositories: [repoA, repoB]) 495 + initialState.selection = .worktree(worktree1.id) 496 + initialState.sidebarSelectedWorktreeIDs = [worktree1.id] 497 + initialState.$collapsedRepositoryIDs.withLock { $0 = [repoA.id, repoB.id] } 498 + let store = TestStore(initialState: initialState) { 499 + RepositoriesFeature() 500 + } 501 + 502 + await store.send(.revealSelectedWorktreeInSidebar) { 503 + $0.$collapsedRepositoryIDs.withLock { $0 = [repoB.id] } 504 + $0.nextPendingSidebarRevealID = 1 505 + $0.pendingSidebarReveal = .init(id: 1, worktreeID: worktree1.id) 506 + } 507 + } 508 + 509 + @Test func consumePendingSidebarRevealClearsMatchingRequest() async { 510 + let worktree = makeWorktree(id: "/tmp/repo/wt", name: "wt") 511 + let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) 512 + var initialState = makeState(repositories: [repository]) 513 + initialState.nextPendingSidebarRevealID = 1 514 + initialState.pendingSidebarReveal = .init(id: 1, worktreeID: worktree.id) 515 + let pendingSidebarReveal = initialState.pendingSidebarReveal 516 + let store = TestStore(initialState: initialState) { 517 + RepositoriesFeature() 518 + } 519 + 520 + await store.send(.consumePendingSidebarReveal(pendingSidebarReveal!.id)) { 521 + $0.pendingSidebarReveal = nil 522 + } 523 + } 524 + 460 525 @Test func createRandomWorktreeWithoutRepositoriesShowsAlert() async { 461 526 let store = TestStore(initialState: RepositoriesFeature.State()) { 462 527 RepositoriesFeature()