native macOS codings agent orchestrator
6
fork

Configure Feed

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

Merge pull request #84 from supabitapp/feature/multi-select-worktrees

Support multi-select sidebar worktree actions and detail summary

authored by

khoi and committed by
GitHub
ed63675b 1546f670

+638 -115
+6 -1
AGENTS.md
··· 4 4 make build-ghostty-xcframework # Rebuild GhosttyKit from Zig source (requires mise) 5 5 make build-app # Build macOS app (Debug) via xcodebuild 6 6 make run-app # Build and launch Debug app 7 - make check # Run swiftformat and swiftlint 7 + make install-dev-build # Build and copy to /Applications 8 + make format # Run swift-format only 9 + make lint # Run swiftlint only (fix + lint) 10 + make check # Run both format and lint 8 11 make test # Run all tests 12 + make log-stream # Stream app logs (subsystem: app.supabit.supacode) 9 13 make bump-version # Bump patch version and create git tag 10 14 make bump-and-release # Bump version and push to trigger release 11 15 ``` ··· 78 82 - Use `@ObservableState` for TCA feature state; use `@Observable` for non-TCA shared stores; never `ObservableObject` 79 83 - Always mark `@Observable` classes with `@MainActor` 80 84 - Modern SwiftUI only: `foregroundStyle()`, `NavigationStack`, `Button` over `onTapGesture()` 85 + - When a new logic changes in the Reducer, always add tests 81 86 - Prefer Swift-native APIs over Foundation where they exist (e.g., `replacing()` not `replacingOccurrences()`) 82 87 - Avoid `GeometryReader` when `containerRelativeFrame()` or `visualEffect()` would work 83 88 - Do not use NSNotification to communicate between reducers.
+1 -1
supacode/Clients/Workspace/WorkspaceClient.swift
··· 6 6 @MainActor @Sendable ( 7 7 _ action: OpenWorktreeAction, 8 8 _ worktree: Worktree, 9 - _ onError: @escaping @MainActor (OpenActionError) -> Void 9 + _ onError: @escaping @MainActor @Sendable (OpenActionError) -> Void 10 10 ) -> Void 11 11 } 12 12
+4 -3
supacode/Domain/OpenWorktreeAction.swift
··· 229 229 defaultPriority.first(where: \.isInstalled) ?? .finder 230 230 } 231 231 232 - func perform(with worktree: Worktree, onError: @escaping (OpenActionError) -> Void) { 232 + func perform(with worktree: Worktree, onError: @escaping @MainActor @Sendable (OpenActionError) -> Void) { 233 + let actionTitle = title 233 234 switch self { 234 235 case .editor: 235 236 return ··· 261 262 Task { @MainActor in 262 263 onError( 263 264 OpenActionError( 264 - title: "Unable to open in \(self.title)", 265 + title: "Unable to open in \(actionTitle)", 265 266 message: error.localizedDescription 266 267 ) 267 268 ) ··· 293 294 Task { @MainActor in 294 295 onError( 295 296 OpenActionError( 296 - title: "Unable to open in \(self.title)", 297 + title: "Unable to open in \(actionTitle)", 297 298 message: error.localizedDescription 298 299 ) 299 300 )
+76
supacode/Features/Repositories/Reducer/RepositoriesFeature.swift
··· 82 82 var pendingPullRequestRefreshByRepositoryID: [Repository.ID: PendingPullRequestRefresh] = [:] 83 83 var inFlightPullRequestRefreshRepositoryIDs: Set<Repository.ID> = [] 84 84 var queuedPullRequestRefreshByRepositoryID: [Repository.ID: PendingPullRequestRefresh] = [:] 85 + var sidebarSelectedWorktreeIDs: Set<Worktree.ID> = [] 85 86 @Presents var alert: AlertState<Alert>? 86 87 } 87 88 ··· 111 112 case reloadRepositories(animated: Bool) 112 113 case repositoriesLoaded([Repository], failures: [LoadFailure], roots: [URL], animated: Bool) 113 114 case selectArchivedWorktrees 115 + case setSidebarSelectedWorktreeIDs(Set<Worktree.ID>) 114 116 case openRepositories([URL]) 115 117 case openRepositoriesFinished( 116 118 [Repository], ··· 141 143 case consumeSetupScript(Worktree.ID) 142 144 case consumeTerminalFocus(Worktree.ID) 143 145 case requestArchiveWorktree(Worktree.ID, Repository.ID) 146 + case requestArchiveWorktrees([ArchiveWorktreeTarget]) 144 147 case archiveWorktreeConfirmed(Worktree.ID, Repository.ID) 145 148 case unarchiveWorktree(Worktree.ID) 146 149 case requestDeleteWorktree(Worktree.ID, Repository.ID) ··· 194 197 let repositoryID: Repository.ID 195 198 } 196 199 200 + struct ArchiveWorktreeTarget: Equatable { 201 + let worktreeID: Worktree.ID 202 + let repositoryID: Repository.ID 203 + } 204 + 197 205 private struct ApplyRepositoriesResult { 198 206 let didPrunePinned: Bool 199 207 let didPruneRepositoryOrder: Bool ··· 208 216 209 217 enum Alert: Equatable { 210 218 case confirmArchiveWorktree(Worktree.ID, Repository.ID) 219 + case confirmArchiveWorktrees([ArchiveWorktreeTarget]) 211 220 case confirmDeleteWorktree(Worktree.ID, Repository.ID) 212 221 case confirmDeleteWorktrees([DeleteWorktreeTarget]) 213 222 case confirmRemoveRepository(Repository.ID) ··· 480 489 481 490 case .selectArchivedWorktrees: 482 491 state.selection = .archivedWorktrees 492 + state.sidebarSelectedWorktreeIDs = [] 483 493 return .send(.delegate(.selectedWorktreeChanged(nil))) 494 + 495 + case .setSidebarSelectedWorktreeIDs(let worktreeIDs): 496 + let validWorktreeIDs = Set(state.orderedWorktreeRows().map(\.id)) 497 + var nextWorktreeIDs = worktreeIDs.intersection(validWorktreeIDs) 498 + if let selectedWorktreeID = state.selectedWorktreeID, validWorktreeIDs.contains(selectedWorktreeID) { 499 + nextWorktreeIDs.insert(selectedWorktreeID) 500 + } 501 + state.sidebarSelectedWorktreeIDs = nextWorktreeIDs 502 + return .none 484 503 485 504 case .selectWorktree(let worktreeID, let focusTerminal): 486 505 state.selection = worktreeID.map(SidebarSelection.worktree) 506 + if let worktreeID { 507 + state.sidebarSelectedWorktreeIDs = [worktreeID] 508 + } else { 509 + state.sidebarSelectedWorktreeIDs = [] 510 + } 487 511 if focusTerminal, let worktreeID { 488 512 state.pendingTerminalFocusWorktreeIDs.insert(worktreeID) 489 513 } ··· 853 877 } 854 878 return .none 855 879 880 + case .requestArchiveWorktrees(let targets): 881 + var validTargets: [ArchiveWorktreeTarget] = [] 882 + var seenWorktreeIDs: Set<Worktree.ID> = [] 883 + for target in targets { 884 + guard seenWorktreeIDs.insert(target.worktreeID).inserted else { continue } 885 + if state.removingRepositoryIDs.contains(target.repositoryID) { 886 + continue 887 + } 888 + guard let repository = state.repositories[id: target.repositoryID], 889 + let worktree = repository.worktrees[id: target.worktreeID] 890 + else { 891 + continue 892 + } 893 + if state.isMainWorktree(worktree) 894 + || state.deletingWorktreeIDs.contains(worktree.id) 895 + || state.isWorktreeArchived(worktree.id) 896 + { 897 + continue 898 + } 899 + validTargets.append(target) 900 + } 901 + guard !validTargets.isEmpty else { 902 + return .none 903 + } 904 + if validTargets.count == 1, let target = validTargets.first { 905 + return .send(.requestArchiveWorktree(target.worktreeID, target.repositoryID)) 906 + } 907 + let count = validTargets.count 908 + state.alert = AlertState { 909 + TextState("Archive \(count) worktrees?") 910 + } actions: { 911 + ButtonState(role: .destructive, action: .confirmArchiveWorktrees(validTargets)) { 912 + TextState("Archive \(count) (⌘↩)") 913 + } 914 + ButtonState(role: .cancel) { 915 + TextState("Cancel") 916 + } 917 + } message: { 918 + TextState("Archive \(count) worktrees?") 919 + } 920 + return .none 921 + 856 922 case .alert(.presented(.confirmArchiveWorktree(let worktreeID, let repositoryID))): 857 923 return .send(.archiveWorktreeConfirmed(worktreeID, repositoryID)) 924 + 925 + case .alert(.presented(.confirmArchiveWorktrees(let targets))): 926 + return .merge( 927 + targets.map { target in 928 + .send(.archiveWorktreeConfirmed(target.worktreeID, target.repositoryID)) 929 + } 930 + ) 858 931 859 932 case .archiveWorktreeConfirmed(let worktreeID, let repositoryID): 860 933 guard let repository = state.repositories[id: repositoryID], ··· 2483 2556 for button in alert.buttons { 2484 2557 if case .confirmArchiveWorktree(let worktreeID, let repositoryID)? = button.action.action { 2485 2558 return .confirmArchiveWorktree(worktreeID, repositoryID) 2559 + } 2560 + if case .confirmArchiveWorktrees(let targets)? = button.action.action { 2561 + return .confirmArchiveWorktrees(targets) 2486 2562 } 2487 2563 if case .confirmDeleteWorktree(let worktreeID, let repositoryID)? = button.action.action { 2488 2564 return .confirmDeleteWorktree(worktreeID, repositoryID)
+2 -5
supacode/Features/Repositories/Views/ArchivedWorktreeRowView.swift
··· 13 13 pullRequest: info?.pullRequest 14 14 ) 15 15 let deleteShortcut = KeyboardShortcut(.delete, modifiers: [.command, .shift]).display 16 + let bodyFontAscender = NSFont.preferredFont(forTextStyle: .body).ascender 16 17 VStack(alignment: .leading, spacing: 2) { 17 18 HStack(alignment: .firstTextBaseline, spacing: 8) { 18 19 Image(systemName: "archivebox") ··· 21 22 .accessibilityHidden(true) 22 23 .frame(width: 16, height: 16) 23 24 .alignmentGuide(.firstTextBaseline) { _ in 24 - bodyFont.ascender 25 + bodyFontAscender 25 26 } 26 27 Text(worktree.name) 27 28 .font(.body) ··· 60 61 .padding(.leading, 24) 61 62 } 62 63 .frame(height: rowHeight, alignment: .center) 63 - } 64 - 65 - private var bodyFont: NSFont { 66 - NSFont.preferredFont(forTextStyle: .body) 67 64 } 68 65 69 66 private var rowHeight: CGFloat {
+2
supacode/Features/Repositories/Views/RepositorySectionView.swift
··· 6 6 let showsTopSeparator: Bool 7 7 let isDragActive: Bool 8 8 let hotkeyRows: [WorktreeRowModel] 9 + let selectedWorktreeIDs: Set<Worktree.ID> 9 10 @Binding var expandedRepoIDs: Set<Repository.ID> 10 11 @Bindable var store: StoreOf<RepositoriesFeature> 11 12 let terminalManager: WorktreeTerminalManager ··· 124 125 repository: repository, 125 126 isExpanded: isExpanded, 126 127 hotkeyRows: hotkeyRows, 128 + selectedWorktreeIDs: selectedWorktreeIDs, 127 129 store: store, 128 130 terminalManager: terminalManager 129 131 )
+66 -21
supacode/Features/Repositories/Views/SidebarListView.swift
··· 4 4 struct SidebarListView: View { 5 5 @Bindable var store: StoreOf<RepositoriesFeature> 6 6 @Binding var expandedRepoIDs: Set<Repository.ID> 7 + @Binding var sidebarSelections: Set<SidebarSelection> 7 8 let terminalManager: WorktreeTerminalManager 8 9 @State private var isDragActive = false 9 10 10 11 var body: some View { 11 - let selection = Binding<SidebarSelection?>( 12 + let state = store.state 13 + let hotkeyRows = state.orderedWorktreeRows(includingRepositoryIDs: expandedRepoIDs) 14 + let orderedRoots = state.orderedRepositoryRoots() 15 + let selectedWorktreeIDs = Set(sidebarSelections.compactMap(\.worktreeID)) 16 + let selection = Binding<Set<SidebarSelection>>( 12 17 get: { 13 - if store.isShowingArchivedWorktrees { 14 - return .archivedWorktrees 18 + var nextSelections = sidebarSelections 19 + if state.isShowingArchivedWorktrees { 20 + nextSelections = [.archivedWorktrees] 21 + } else { 22 + nextSelections.remove(.archivedWorktrees) 23 + if let selectedWorktreeID = state.selectedWorktreeID { 24 + nextSelections.insert(.worktree(selectedWorktreeID)) 25 + } 15 26 } 16 - return store.selectedWorktreeID.map(SidebarSelection.worktree) 27 + return nextSelections 17 28 }, 18 29 set: { newValue in 19 - switch newValue { 20 - case .archivedWorktrees: 21 - store.send(.selectArchivedWorktrees) 22 - case .worktree(let id): 23 - store.send(.selectWorktree(id, focusTerminal: true)) 24 - case .repository(let id): 25 - guard let repo = store.state.repositories[id: id], 26 - !store.state.isRemovingRepository(repo) 27 - else { return } 30 + var nextSelections = newValue 31 + let repositorySelections: [Repository.ID] = nextSelections.compactMap { selection in 32 + guard case .repository(let repositoryID) = selection else { return nil } 33 + return repositoryID 34 + } 35 + if !repositorySelections.isEmpty { 28 36 withAnimation(.easeOut(duration: 0.2)) { 29 - if expandedRepoIDs.contains(id) { 30 - expandedRepoIDs.remove(id) 31 - } else { 32 - expandedRepoIDs.insert(id) 37 + for repositoryID in repositorySelections { 38 + guard let repository = store.state.repositories[id: repositoryID], 39 + !store.state.isRemovingRepository(repository) 40 + else { 41 + continue 42 + } 43 + if expandedRepoIDs.contains(repositoryID) { 44 + expandedRepoIDs.remove(repositoryID) 45 + } else { 46 + expandedRepoIDs.insert(repositoryID) 47 + } 33 48 } 34 49 } 35 - case nil: 50 + nextSelections = Set( 51 + nextSelections.filter { 52 + if case .repository = $0 { 53 + return false 54 + } 55 + return true 56 + }) 57 + } 58 + 59 + if nextSelections.contains(.archivedWorktrees) { 60 + sidebarSelections = [.archivedWorktrees] 61 + store.send(.selectArchivedWorktrees) 62 + return 63 + } 64 + 65 + let worktreeIDs = Set(nextSelections.compactMap(\.worktreeID)) 66 + guard !worktreeIDs.isEmpty else { 67 + if !repositorySelections.isEmpty { 68 + return 69 + } 70 + sidebarSelections = [] 36 71 store.send(.selectWorktree(nil)) 72 + return 37 73 } 74 + sidebarSelections = Set(worktreeIDs.map(SidebarSelection.worktree)) 75 + if let selectedWorktreeID = state.selectedWorktreeID, worktreeIDs.contains(selectedWorktreeID) { 76 + return 77 + } 78 + let nextPrimarySelection = 79 + hotkeyRows.map(\.id).first(where: worktreeIDs.contains) 80 + ?? worktreeIDs.first 81 + store.send(.selectWorktree(nextPrimarySelection, focusTerminal: true)) 38 82 } 39 83 ) 40 - let state = store.state 41 - let hotkeyRows = state.orderedWorktreeRows(includingRepositoryIDs: expandedRepoIDs) 42 - let orderedRoots = state.orderedRepositoryRoots() 43 84 let repositoriesByID = Dictionary(uniqueKeysWithValues: store.repositories.map { ($0.id, $0) }) 44 85 List(selection: selection) { 45 86 if orderedRoots.isEmpty { ··· 50 91 showsTopSeparator: index > 0, 51 92 isDragActive: isDragActive, 52 93 hotkeyRows: hotkeyRows, 94 + selectedWorktreeIDs: selectedWorktreeIDs, 53 95 expandedRepoIDs: $expandedRepoIDs, 54 96 store: store, 55 97 terminalManager: terminalManager ··· 99 141 showsTopSeparator: index > 0, 100 142 isDragActive: isDragActive, 101 143 hotkeyRows: hotkeyRows, 144 + selectedWorktreeIDs: selectedWorktreeIDs, 102 145 expandedRepoIDs: $expandedRepoIDs, 103 146 store: store, 104 147 terminalManager: terminalManager ··· 155 198 let hasCommandModifier = keyPress.modifiers.contains(.command) 156 199 if hasCommandModifier { return .ignored } 157 200 guard let worktreeID = store.selectedWorktreeID, 201 + state.sidebarSelectedWorktreeIDs.count == 1, 202 + state.sidebarSelectedWorktreeIDs.contains(worktreeID), 158 203 let terminalState = terminalManager.stateIfExists(for: worktreeID) 159 204 else { return .ignored } 160 205 terminalState.focusAndInsertText(keyPress.characters)
+140 -31
supacode/Features/Repositories/Views/SidebarView.swift
··· 6 6 @Bindable var store: StoreOf<RepositoriesFeature> 7 7 let terminalManager: WorktreeTerminalManager 8 8 @Shared(.appStorage("sidebarCollapsedRepositoryIDs")) private var collapsedRepositoryIDs: [Repository.ID] = [] 9 + @State private var sidebarSelections: Set<SidebarSelection> = [] 9 10 10 11 var body: some View { 11 12 let state = store.state 12 13 let repositoryIDs = Set(state.repositories.map(\.id)) 14 + let expandedRepoIDs = expandedRepositoryIDs(state: state, repositoryIDs: repositoryIDs) 15 + let expandedRepoIDsBinding = expandedRepoIDsBinding( 16 + repositoryIDs: repositoryIDs, 17 + expandedRepoIDs: expandedRepoIDs 18 + ) 19 + let visibleHotkeyRows = state.orderedWorktreeRows(includingRepositoryIDs: expandedRepoIDs) 20 + let visibleWorktreeIDs = Set(visibleHotkeyRows.map(\.id)) 21 + let effectiveSelectedRows = selectedRows(state: state) 22 + let confirmWorktreeAction = makeConfirmWorktreeAction(state: state) 23 + let archiveWorktreeAction = makeArchiveWorktreeAction(rows: effectiveSelectedRows) 24 + let deleteWorktreeAction = makeDeleteWorktreeAction(rows: effectiveSelectedRows) 25 + 26 + return SidebarListView( 27 + store: store, 28 + expandedRepoIDs: expandedRepoIDsBinding, 29 + sidebarSelections: $sidebarSelections, 30 + terminalManager: terminalManager 31 + ) 32 + .focusedSceneValue(\.confirmWorktreeAction, confirmWorktreeAction) 33 + .focusedSceneValue(\.archiveWorktreeAction, archiveWorktreeAction) 34 + .focusedSceneValue(\.deleteWorktreeAction, deleteWorktreeAction) 35 + .focusedSceneValue(\.visibleHotkeyWorktreeRows, visibleHotkeyRows) 36 + .onAppear { syncSidebarSelections(state: state, visibleWorktreeIDs: visibleWorktreeIDs) } 37 + .onChange(of: state.selection) { _, _ in 38 + syncSidebarSelections(state: state, visibleWorktreeIDs: visibleWorktreeIDs) 39 + } 40 + .onChange(of: visibleHotkeyRows.map(\.id)) { _, _ in 41 + syncSidebarSelections(state: state, visibleWorktreeIDs: visibleWorktreeIDs) 42 + } 43 + .onChange(of: sidebarSelections) { _, newValue in 44 + store.send(.setSidebarSelectedWorktreeIDs(selectedWorktreeIDs(from: newValue))) 45 + } 46 + .onChange(of: repositoryIDs) { _, newValue in 47 + let collapsed = Set(collapsedRepositoryIDs).intersection(newValue) 48 + $collapsedRepositoryIDs.withLock { 49 + $0 = Array(collapsed).sorted() 50 + } 51 + } 52 + } 53 + 54 + private func expandedRepositoryIDs( 55 + state: RepositoriesFeature.State, 56 + repositoryIDs: Set<Repository.ID> 57 + ) -> Set<Repository.ID> { 13 58 let pendingRepositoryIDs = Set(state.pendingWorktrees.map(\.repositoryID)) 14 59 let collapsedSet = Set(collapsedRepositoryIDs).intersection(repositoryIDs) 15 - let expandedRepoIDs = repositoryIDs.subtracting(collapsedSet).union(pendingRepositoryIDs) 16 - let expandedRepoIDsBinding = Binding<Set<Repository.ID>>( 17 - get: { 18 - expandedRepoIDs 19 - }, 60 + return repositoryIDs.subtracting(collapsedSet).union(pendingRepositoryIDs) 61 + } 62 + 63 + private func expandedRepoIDsBinding( 64 + repositoryIDs: Set<Repository.ID>, 65 + expandedRepoIDs: Set<Repository.ID> 66 + ) -> Binding<Set<Repository.ID>> { 67 + Binding<Set<Repository.ID>>( 68 + get: { expandedRepoIDs }, 20 69 set: { newValue in 21 70 let collapsed = repositoryIDs.subtracting(newValue) 22 71 $collapsedRepositoryIDs.withLock { ··· 24 73 } 25 74 } 26 75 ) 27 - let visibleHotkeyRows = state.orderedWorktreeRows(includingRepositoryIDs: expandedRepoIDs) 76 + } 77 + 78 + private func selectedRows(state: RepositoriesFeature.State) -> [WorktreeRowModel] { 28 79 let selectedRow = state.selectedRow(for: state.selectedWorktreeID) 29 - let confirmWorktreeAction: (() -> Void)? = { 30 - guard let alert = state.confirmWorktreeAlert else { return nil } 31 - return { 32 - store.send(.alert(.presented(alert))) 80 + let selectedWorktreeIDs = state.sidebarSelectedWorktreeIDs 81 + let selectedRows = state.orderedWorktreeRows().filter { selectedWorktreeIDs.contains($0.id) } 82 + return selectedRows.isEmpty ? (selectedRow.map { [$0] } ?? []) : selectedRows 83 + } 84 + 85 + private func makeConfirmWorktreeAction( 86 + state: RepositoriesFeature.State 87 + ) -> (() -> Void)? { 88 + guard let alert = state.confirmWorktreeAlert else { return nil } 89 + return { 90 + store.send(.alert(.presented(alert))) 91 + } 92 + } 93 + 94 + private func makeArchiveWorktreeAction( 95 + rows: [WorktreeRowModel] 96 + ) -> (() -> Void)? { 97 + let targets = rows 98 + .filter { $0.isRemovable && !$0.isMainWorktree && !$0.isDeleting } 99 + .map { 100 + RepositoriesFeature.ArchiveWorktreeTarget( 101 + worktreeID: $0.id, 102 + repositoryID: $0.repositoryID 103 + ) 33 104 } 34 - }() 35 - let archiveWorktreeAction: (() -> Void)? = { 36 - guard let selectedRow, selectedRow.isRemovable, !selectedRow.isMainWorktree else { return nil } 37 - return { 38 - store.send(.requestArchiveWorktree(selectedRow.id, selectedRow.repositoryID)) 105 + guard !targets.isEmpty else { return nil } 106 + return { 107 + if targets.count == 1, let target = targets.first { 108 + store.send(.requestArchiveWorktree(target.worktreeID, target.repositoryID)) 109 + } else { 110 + store.send(.requestArchiveWorktrees(targets)) 39 111 } 40 - }() 41 - let deleteWorktreeAction: (() -> Void)? = { 42 - guard let selectedRow, selectedRow.isRemovable else { return nil } 43 - return { 44 - store.send(.requestDeleteWorktree(selectedRow.id, selectedRow.repositoryID)) 112 + } 113 + } 114 + 115 + private func makeDeleteWorktreeAction( 116 + rows: [WorktreeRowModel] 117 + ) -> (() -> Void)? { 118 + let targets = rows 119 + .filter { $0.isRemovable && !$0.isDeleting } 120 + .map { 121 + RepositoriesFeature.DeleteWorktreeTarget( 122 + worktreeID: $0.id, 123 + repositoryID: $0.repositoryID 124 + ) 45 125 } 46 - }() 47 - SidebarListView(store: store, expandedRepoIDs: expandedRepoIDsBinding, terminalManager: terminalManager) 48 - .focusedSceneValue(\.confirmWorktreeAction, confirmWorktreeAction) 49 - .focusedSceneValue(\.archiveWorktreeAction, archiveWorktreeAction) 50 - .focusedSceneValue(\.deleteWorktreeAction, deleteWorktreeAction) 51 - .focusedSceneValue(\.visibleHotkeyWorktreeRows, visibleHotkeyRows) 52 - .onChange(of: repositoryIDs) { _, newValue in 53 - let collapsed = Set(collapsedRepositoryIDs).intersection(newValue) 54 - $collapsedRepositoryIDs.withLock { 55 - $0 = Array(collapsed).sorted() 56 - } 126 + guard !targets.isEmpty else { return nil } 127 + return { 128 + if targets.count == 1, let target = targets.first { 129 + store.send(.requestDeleteWorktree(target.worktreeID, target.repositoryID)) 130 + } else { 131 + store.send(.requestDeleteWorktrees(targets)) 57 132 } 133 + } 134 + } 135 + 136 + private func syncSidebarSelections( 137 + state: RepositoriesFeature.State, 138 + visibleWorktreeIDs: Set<Worktree.ID> 139 + ) { 140 + sidebarSelections = normalizedSidebarSelections( 141 + state: state, 142 + visibleWorktreeIDs: visibleWorktreeIDs 143 + ) 144 + store.send(.setSidebarSelectedWorktreeIDs(selectedWorktreeIDs(from: sidebarSelections))) 145 + } 146 + 147 + private func normalizedSidebarSelections( 148 + state: RepositoriesFeature.State, 149 + visibleWorktreeIDs: Set<Worktree.ID> 150 + ) -> Set<SidebarSelection> { 151 + if state.isShowingArchivedWorktrees { 152 + return [.archivedWorktrees] 153 + } 154 + var normalized = Set( 155 + state.sidebarSelectedWorktreeIDs 156 + .intersection(visibleWorktreeIDs) 157 + .map(SidebarSelection.worktree) 158 + ) 159 + if let selectedWorktreeID = state.selectedWorktreeID { 160 + normalized.insert(.worktree(selectedWorktreeID)) 161 + } 162 + return normalized 163 + } 164 + 165 + private func selectedWorktreeIDs(from selections: Set<SidebarSelection>) -> Set<Worktree.ID> { 166 + Set(selections.compactMap(\.worktreeID)) 58 167 } 59 168 }
+139 -29
supacode/Features/Repositories/Views/WorktreeDetailView.swift
··· 15 15 let repositories = state.repositories 16 16 let selectedRow = repositories.selectedRow(for: repositories.selectedWorktreeID) 17 17 let selectedWorktree = repositories.worktree(for: repositories.selectedWorktreeID) 18 + let selectedWorktreeSummaries = selectedWorktreeSummaries(from: repositories) 19 + let showsMultiSelectionSummary = shouldShowMultiSelectionSummary( 20 + repositories: repositories, 21 + selectedWorktreeSummaries: selectedWorktreeSummaries 22 + ) 18 23 let loadingInfo = loadingInfo( 19 24 for: selectedRow, 20 25 selectedWorktreeID: repositories.selectedWorktreeID, 21 26 repositories: repositories 22 27 ) 23 - let hasActiveWorktree = selectedWorktree != nil && loadingInfo == nil 28 + let hasActiveWorktree = 29 + selectedWorktree != nil 30 + && loadingInfo == nil 31 + && !showsMultiSelectionSummary 24 32 let openActionSelection = state.openActionSelection 25 33 let runScriptEnabled = hasActiveWorktree 26 34 let runScriptIsRunning = selectedWorktree.flatMap { state.runScriptStatusByWorktreeID[$0.id] } == true ··· 28 36 let unseenNotificationWorktreeCount = notificationGroups.reduce(0) { count, repository in 29 37 count + repository.unseenWorktreeCount 30 38 } 31 - let content = Group { 32 - if repositories.isShowingArchivedWorktrees { 33 - ArchivedWorktreesDetailView( 34 - store: store.scope(state: \.repositories, action: \.repositories) 35 - ) 36 - } else if let loadingInfo { 37 - WorktreeLoadingView(info: loadingInfo) 38 - } else if let selectedWorktree { 39 - let shouldRunSetupScript = repositories.pendingSetupScriptWorktreeIDs.contains(selectedWorktree.id) 40 - let shouldFocusTerminal = repositories.shouldFocusTerminal(for: selectedWorktree.id) 41 - WorktreeTerminalTabsView( 42 - worktree: selectedWorktree, 43 - manager: terminalManager, 44 - shouldRunSetupScript: shouldRunSetupScript, 45 - forceAutoFocus: shouldFocusTerminal, 46 - createTab: { store.send(.newTerminal) } 47 - ) 48 - .id(selectedWorktree.id) 49 - .frame(maxWidth: .infinity, maxHeight: .infinity) 50 - .onAppear { 51 - if shouldFocusTerminal { 52 - store.send(.repositories(.consumeTerminalFocus(selectedWorktree.id))) 53 - } 54 - } 55 - } else { 56 - EmptyStateView(store: store.scope(state: \.repositories, action: \.repositories)) 57 - } 58 - } 39 + let content = detailContent( 40 + repositories: repositories, 41 + loadingInfo: loadingInfo, 42 + selectedWorktree: selectedWorktree, 43 + selectedWorktreeSummaries: selectedWorktreeSummaries 44 + ) 59 45 .toolbar(removing: .title) 60 46 .toolbar { 61 47 if hasActiveWorktree, let selectedWorktree { ··· 105 91 runScriptIsRunning: runScriptIsRunning 106 92 ) 107 93 return applyFocusedActions(content: content, actions: actions) 94 + } 95 + 96 + private func selectedWorktreeSummaries( 97 + from repositories: RepositoriesFeature.State 98 + ) -> [MultiSelectedWorktreeSummary] { 99 + repositories.sidebarSelectedWorktreeIDs 100 + .compactMap { worktreeID in 101 + repositories.selectedRow(for: worktreeID).map { 102 + MultiSelectedWorktreeSummary( 103 + id: $0.id, 104 + name: $0.name, 105 + repositoryName: repositories.repositoryName(for: $0.repositoryID) 106 + ) 107 + } 108 + } 109 + .sorted { lhs, rhs in 110 + let lhsRepository = lhs.repositoryName ?? "" 111 + let rhsRepository = rhs.repositoryName ?? "" 112 + if lhsRepository == rhsRepository { 113 + return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending 114 + } 115 + return lhsRepository.localizedCaseInsensitiveCompare(rhsRepository) == .orderedAscending 116 + } 117 + } 118 + 119 + private func shouldShowMultiSelectionSummary( 120 + repositories: RepositoriesFeature.State, 121 + selectedWorktreeSummaries: [MultiSelectedWorktreeSummary] 122 + ) -> Bool { 123 + !repositories.isShowingArchivedWorktrees 124 + && selectedWorktreeSummaries.count > 1 125 + } 126 + 127 + @ViewBuilder 128 + private func detailContent( 129 + repositories: RepositoriesFeature.State, 130 + loadingInfo: WorktreeLoadingInfo?, 131 + selectedWorktree: Worktree?, 132 + selectedWorktreeSummaries: [MultiSelectedWorktreeSummary] 133 + ) -> some View { 134 + if repositories.isShowingArchivedWorktrees { 135 + ArchivedWorktreesDetailView( 136 + store: store.scope(state: \.repositories, action: \.repositories) 137 + ) 138 + } else if shouldShowMultiSelectionSummary( 139 + repositories: repositories, 140 + selectedWorktreeSummaries: selectedWorktreeSummaries 141 + ) { 142 + MultiSelectedWorktreesDetailView(rows: selectedWorktreeSummaries) 143 + } else if let loadingInfo { 144 + WorktreeLoadingView(info: loadingInfo) 145 + } else if let selectedWorktree { 146 + let shouldRunSetupScript = repositories.pendingSetupScriptWorktreeIDs.contains(selectedWorktree.id) 147 + let shouldFocusTerminal = repositories.shouldFocusTerminal(for: selectedWorktree.id) 148 + WorktreeTerminalTabsView( 149 + worktree: selectedWorktree, 150 + manager: terminalManager, 151 + shouldRunSetupScript: shouldRunSetupScript, 152 + forceAutoFocus: shouldFocusTerminal, 153 + createTab: { store.send(.newTerminal) } 154 + ) 155 + .id(selectedWorktree.id) 156 + .frame(maxWidth: .infinity, maxHeight: .infinity) 157 + .onAppear { 158 + if shouldFocusTerminal { 159 + store.send(.repositories(.consumeTerminalFocus(selectedWorktree.id))) 160 + } 161 + } 162 + } else { 163 + EmptyStateView(store: store.scope(state: \.repositories, action: \.repositories)) 164 + } 108 165 } 109 166 110 167 private func applyFocusedActions<Content: View>( ··· 349 406 ) 350 407 } 351 408 return nil 409 + } 410 + } 411 + 412 + private struct MultiSelectedWorktreeSummary: Identifiable { 413 + let id: Worktree.ID 414 + let name: String 415 + let repositoryName: String? 416 + } 417 + 418 + private struct MultiSelectedWorktreesDetailView: View { 419 + let rows: [MultiSelectedWorktreeSummary] 420 + 421 + private let visibleRowsLimit = 8 422 + 423 + var body: some View { 424 + let archiveShortcut = KeyboardShortcut(.delete, modifiers: .command).display 425 + let deleteShortcut = KeyboardShortcut(.delete, modifiers: [.command, .shift]).display 426 + VStack(alignment: .leading, spacing: 16) { 427 + Text("\(rows.count) worktrees selected") 428 + .font(.title3) 429 + VStack(alignment: .leading, spacing: 8) { 430 + ForEach(Array(rows.prefix(visibleRowsLimit))) { row in 431 + HStack(alignment: .firstTextBaseline, spacing: 8) { 432 + Text(row.name) 433 + .lineLimit(1) 434 + if let repositoryName = row.repositoryName { 435 + Text(repositoryName) 436 + .foregroundStyle(.secondary) 437 + .lineLimit(1) 438 + } 439 + } 440 + .font(.body) 441 + } 442 + if rows.count > visibleRowsLimit { 443 + Text("+\(rows.count - visibleRowsLimit) more") 444 + .font(.caption) 445 + .foregroundStyle(.secondary) 446 + } 447 + } 448 + Divider() 449 + VStack(alignment: .leading, spacing: 6) { 450 + Text("Available actions") 451 + .font(.headline) 452 + Text("Archive selected (\(archiveShortcut))") 453 + Text("Delete selected (\(deleteShortcut))") 454 + Text("Right-click any selected worktree to apply actions to all selected worktrees.") 455 + } 456 + .font(.caption) 457 + .foregroundStyle(.secondary) 458 + Spacer(minLength: 0) 459 + } 460 + .padding(20) 461 + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) 352 462 } 353 463 } 354 464
+2 -5
supacode/Features/Repositories/Views/WorktreeRow.swift
··· 36 36 let showsPullRequestTag = display.pullRequest != nil && display.pullRequestBadgeStyle != nil 37 37 let nameColor = colorScheme == .dark ? Color.white : Color.primary 38 38 let detailText = worktreeName.isEmpty ? name : worktreeName 39 + let bodyFontAscender = NSFont.preferredFont(forTextStyle: .body).ascender 39 40 VStack(alignment: .leading, spacing: 2) { 40 41 HStack(alignment: .firstTextBaseline, spacing: 6) { 41 42 ZStack { ··· 64 65 } 65 66 .frame(width: 16, height: 16) 66 67 .alignmentGuide(.firstTextBaseline) { _ in 67 - bodyFont.ascender 68 + bodyFontAscender 68 69 } 69 70 Text(name) 70 71 .font(.body) ··· 128 129 return nil 129 130 } 130 131 return PullRequestMergeReadiness(pullRequest: pullRequest) 131 - } 132 - 133 - private var bodyFont: NSFont { 134 - NSFont.preferredFont(forTextStyle: .body) 135 132 } 136 133 137 134 private var worktreeRowHeight: CGFloat {
+60 -9
supacode/Features/Repositories/Views/WorktreeRowsView.swift
··· 6 6 let repository: Repository 7 7 let isExpanded: Bool 8 8 let hotkeyRows: [WorktreeRowModel] 9 + let selectedWorktreeIDs: Set<Worktree.ID> 9 10 @Bindable var store: StoreOf<RepositoriesFeature> 10 11 let terminalManager: WorktreeTerminalManager 11 12 @Environment(CommandKeyObserver.self) private var commandKeyObserver ··· 177 178 } 178 179 179 180 private func worktreeRowView(_ row: WorktreeRowModel, config: WorktreeRowViewConfig) -> some View { 180 - let isSelected = row.id == store.state.selectedWorktreeID 181 + let isSelected = selectedWorktreeIDs.contains(row.id) 181 182 let taskStatus = terminalManager.taskStatus(for: row.id) 182 183 let isRunScriptRunning = terminalManager.isRunScriptRunning(for: row.id) 183 184 return WorktreeRow( ··· 211 212 private func rowContextMenu(worktree: Worktree, row: WorktreeRowModel) -> some View { 212 213 let archiveShortcut = KeyboardShortcut(.delete, modifiers: .command).display 213 214 let deleteShortcut = KeyboardShortcut(.delete, modifiers: [.command, .shift]).display 215 + let contextRows = contextActionRows(for: row) 216 + let isBulkSelection = contextRows.count > 1 217 + let archiveTargets = contextRows 218 + .filter { !$0.isMainWorktree } 219 + .map { 220 + RepositoriesFeature.ArchiveWorktreeTarget( 221 + worktreeID: $0.id, 222 + repositoryID: $0.repositoryID 223 + ) 224 + } 225 + let deleteTargets = contextRows.map { 226 + RepositoriesFeature.DeleteWorktreeTarget( 227 + worktreeID: $0.id, 228 + repositoryID: $0.repositoryID 229 + ) 230 + } 231 + let archiveTitle = 232 + isBulkSelection 233 + ? "Archive Selected Worktrees (\(archiveShortcut))" 234 + : "Archive Worktree (\(archiveShortcut))" 235 + let deleteTitle = 236 + isBulkSelection 237 + ? "Delete Selected Worktrees (\(deleteShortcut))" 238 + : "Delete Worktree (\(deleteShortcut))" 214 239 if !row.isMainWorktree { 215 240 if row.isPinned { 216 241 Button("Unpin") { ··· 228 253 NSPasteboard.general.clearContents() 229 254 NSPasteboard.general.setString(worktree.workingDirectory.path, forType: .string) 230 255 } 231 - Button("Archive Worktree (\(archiveShortcut))") { 232 - archiveWorktree(worktree.id) 256 + Button(archiveTitle) { 257 + archiveWorktrees(archiveTargets) 233 258 } 234 259 .help( 235 - row.isMainWorktree 260 + archiveTargets.isEmpty 236 261 ? "Main worktree can't be archived" 237 - : "Archive Worktree (\(archiveShortcut))" 262 + : archiveTitle 238 263 ) 239 - .disabled(row.isMainWorktree) 240 - Button("Delete Worktree (\(deleteShortcut))", role: .destructive) { 241 - store.send(.requestDeleteWorktree(worktree.id, repository.id)) 264 + .disabled(archiveTargets.isEmpty) 265 + Button(deleteTitle, role: .destructive) { 266 + deleteWorktrees(deleteTargets) 242 267 } 243 - .help("Delete Worktree (\(deleteShortcut))") 268 + .help(deleteTitle) 244 269 } 245 270 246 271 private func worktreeShortcutHint(for index: Int?) -> String? { ··· 260 285 261 286 private func archiveWorktree(_ worktreeID: Worktree.ID) { 262 287 store.send(.requestArchiveWorktree(worktreeID, repository.id)) 288 + } 289 + 290 + private func contextActionRows(for row: WorktreeRowModel) -> [WorktreeRowModel] { 291 + guard selectedWorktreeIDs.count > 1, selectedWorktreeIDs.contains(row.id) else { 292 + return [row] 293 + } 294 + let rows = selectedWorktreeIDs.compactMap { store.state.selectedRow(for: $0) } 295 + return rows.isEmpty ? [row] : rows 296 + } 297 + 298 + private func archiveWorktrees(_ targets: [RepositoriesFeature.ArchiveWorktreeTarget]) { 299 + guard !targets.isEmpty else { return } 300 + if targets.count == 1, let target = targets.first { 301 + store.send(.requestArchiveWorktree(target.worktreeID, target.repositoryID)) 302 + } else { 303 + store.send(.requestArchiveWorktrees(targets)) 304 + } 305 + } 306 + 307 + private func deleteWorktrees(_ targets: [RepositoriesFeature.DeleteWorktreeTarget]) { 308 + guard !targets.isEmpty else { return } 309 + if targets.count == 1, let target = targets.first { 310 + store.send(.requestDeleteWorktree(target.worktreeID, target.repositoryID)) 311 + } else { 312 + store.send(.requestDeleteWorktrees(targets)) 313 + } 263 314 } 264 315 265 316 private func worktreeName(for row: WorktreeRowModel) -> String {
+10 -4
supacode/Features/Terminal/Views/WindowFocusObserverView.swift
··· 59 59 object: window, 60 60 queue: .main 61 61 ) { [weak self] _ in 62 - self?.emitActivityIfNeeded() 62 + Task { @MainActor [weak self] in 63 + self?.emitActivityIfNeeded() 64 + } 63 65 }) 64 66 observers.append( 65 67 center.addObserver( ··· 67 69 object: window, 68 70 queue: .main 69 71 ) { [weak self] _ in 70 - self?.emitActivityIfNeeded() 72 + Task { @MainActor [weak self] in 73 + self?.emitActivityIfNeeded() 74 + } 71 75 }) 72 76 observers.append( 73 77 center.addObserver( ··· 75 79 object: window, 76 80 queue: .main 77 81 ) { [weak self] _ in 78 - self?.emitActivityIfNeeded() 82 + Task { @MainActor [weak self] in 83 + self?.emitActivityIfNeeded() 84 + } 79 85 }) 80 86 emitActivityIfNeeded(force: true) 81 87 } ··· 97 103 observers.removeAll() 98 104 } 99 105 100 - deinit { 106 + isolated deinit { 101 107 clearObservers() 102 108 } 103 109 }
+15 -5
supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift
··· 177 177 object: window, 178 178 queue: .main 179 179 ) { [weak self] _ in 180 - self?.windowDidChangeScreen() 180 + Task { @MainActor [weak self] in 181 + self?.windowDidChangeScreen() 182 + } 181 183 }) 182 184 } 183 185 ··· 1392 1394 object: scrollView.contentView, 1393 1395 queue: .main 1394 1396 ) { [weak self] _ in 1395 - self?.handleScrollChange() 1397 + Task { @MainActor [weak self] in 1398 + self?.handleScrollChange() 1399 + } 1396 1400 }) 1397 1401 1398 1402 observers.append( ··· 1419 1423 object: scrollView, 1420 1424 queue: .main 1421 1425 ) { [weak self] _ in 1422 - self?.handleLiveScroll() 1426 + Task { @MainActor [weak self] in 1427 + self?.handleLiveScroll() 1428 + } 1423 1429 }) 1424 1430 1425 1431 observers.append( ··· 1428 1434 object: nil, 1429 1435 queue: nil 1430 1436 ) { [weak self] _ in 1431 - self?.handleScrollerStyleChange() 1437 + Task { @MainActor [weak self] in 1438 + self?.handleScrollerStyleChange() 1439 + } 1432 1440 }) 1433 1441 1434 1442 observers.append( ··· 1437 1445 object: nil, 1438 1446 queue: .main 1439 1447 ) { [weak self] _ in 1440 - self?.refreshAppearance() 1448 + Task { @MainActor [weak self] in 1449 + self?.refreshAppearance() 1450 + } 1441 1451 }) 1442 1452 } 1443 1453
+115 -1
supacodeTests/RepositoriesFeatureTests.swift
··· 71 71 72 72 await store.send(.selectWorktree(worktree.id)) { 73 73 $0.selection = .worktree(worktree.id) 74 + $0.sidebarSelectedWorktreeIDs = [worktree.id] 75 + } 76 + await store.receive(\.delegate.selectedWorktreeChanged) 77 + } 78 + 79 + @Test func selectWorktreeCollapsesSidebarSelectedWorktreeIDs() async { 80 + let wt1 = makeWorktree(id: "/tmp/repo/wt1", name: "wt1", repoRoot: "/tmp/repo") 81 + let wt2 = makeWorktree(id: "/tmp/repo/wt2", name: "wt2", repoRoot: "/tmp/repo") 82 + let wt3 = makeWorktree(id: "/tmp/repo/wt3", name: "wt3", repoRoot: "/tmp/repo") 83 + let repository = makeRepository(id: "/tmp/repo", worktrees: [wt1, wt2, wt3]) 84 + var initialState = makeState(repositories: [repository]) 85 + initialState.selection = .worktree(wt1.id) 86 + initialState.sidebarSelectedWorktreeIDs = [wt1.id, wt2.id, wt3.id] 87 + let store = TestStore(initialState: initialState) { 88 + RepositoriesFeature() 89 + } 90 + 91 + await store.send(.selectWorktree(wt2.id)) { 92 + $0.selection = .worktree(wt2.id) 93 + $0.sidebarSelectedWorktreeIDs = [wt2.id] 94 + } 95 + await store.receive(\.delegate.selectedWorktreeChanged) 96 + } 97 + 98 + @Test func setSidebarSelectedWorktreeIDsKeepsSelectedAndPrunesUnknown() async { 99 + let worktree1 = makeWorktree(id: "/tmp/repo/wt1", name: "wt1", repoRoot: "/tmp/repo") 100 + let worktree2 = makeWorktree(id: "/tmp/repo/wt2", name: "wt2", repoRoot: "/tmp/repo") 101 + let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree1, worktree2]) 102 + var initialState = makeState(repositories: [repository]) 103 + initialState.selection = .worktree(worktree1.id) 104 + let store = TestStore(initialState: initialState) { 105 + RepositoriesFeature() 106 + } 107 + 108 + await store.send( 109 + .setSidebarSelectedWorktreeIDs( 110 + [worktree2.id, "/tmp/repo/unknown"] 111 + ) 112 + ) { 113 + $0.sidebarSelectedWorktreeIDs = [worktree1.id, worktree2.id] 114 + } 115 + } 116 + 117 + @Test func selectArchivedWorktreesClearsSidebarSelectedWorktreeIDs() async { 118 + let worktree1 = makeWorktree(id: "/tmp/repo/wt1", name: "wt1", repoRoot: "/tmp/repo") 119 + let worktree2 = makeWorktree(id: "/tmp/repo/wt2", name: "wt2", repoRoot: "/tmp/repo") 120 + let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree1, worktree2]) 121 + var initialState = makeState(repositories: [repository]) 122 + initialState.selection = .worktree(worktree1.id) 123 + initialState.sidebarSelectedWorktreeIDs = [worktree1.id, worktree2.id] 124 + let store = TestStore(initialState: initialState) { 125 + RepositoriesFeature() 126 + } 127 + 128 + await store.send(.selectArchivedWorktrees) { 129 + $0.selection = .archivedWorktrees 130 + $0.sidebarSelectedWorktreeIDs = [] 74 131 } 75 132 await store.receive(\.delegate.selectedWorktreeChanged) 76 133 } ··· 368 425 } 369 426 370 427 await store.send(.requestArchiveWorktree(worktree.id, repository.id)) { 428 + $0.alert = expectedAlert 429 + } 430 + } 431 + 432 + @Test func requestArchiveWorktreesShowsBatchConfirmation() async { 433 + let worktree1 = makeWorktree(id: "/tmp/repo/wt1", name: "owl", repoRoot: "/tmp/repo") 434 + let worktree2 = makeWorktree(id: "/tmp/repo/wt2", name: "hawk", repoRoot: "/tmp/repo") 435 + let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree1, worktree2]) 436 + let targets = [ 437 + RepositoriesFeature.ArchiveWorktreeTarget(worktreeID: worktree1.id, repositoryID: repository.id), 438 + RepositoriesFeature.ArchiveWorktreeTarget(worktreeID: worktree2.id, repositoryID: repository.id), 439 + ] 440 + let store = TestStore(initialState: makeState(repositories: [repository])) { 441 + RepositoriesFeature() 442 + } 443 + 444 + let expectedAlert = AlertState<RepositoriesFeature.Alert> { 445 + TextState("Archive 2 worktrees?") 446 + } actions: { 447 + ButtonState(role: .destructive, action: .confirmArchiveWorktrees(targets)) { 448 + TextState("Archive 2 (⌘↩)") 449 + } 450 + ButtonState(role: .cancel) { 451 + TextState("Cancel") 452 + } 453 + } message: { 454 + TextState("Archive 2 worktrees?") 455 + } 456 + 457 + await store.send(.requestArchiveWorktrees(targets)) { 371 458 $0.alert = expectedAlert 372 459 } 373 460 } ··· 1331 1418 await store.send(.selectNextWorktree) 1332 1419 await store.receive(\.selectWorktree) { 1333 1420 $0.selection = .worktree(wt1.id) 1421 + $0.sidebarSelectedWorktreeIDs = [wt1.id] 1334 1422 } 1335 1423 await store.receive(\.delegate.selectedWorktreeChanged) 1336 1424 } ··· 1348 1436 await store.send(.selectPreviousWorktree) 1349 1437 await store.receive(\.selectWorktree) { 1350 1438 $0.selection = .worktree(wt2.id) 1439 + $0.sidebarSelectedWorktreeIDs = [wt2.id] 1351 1440 } 1352 1441 await store.receive(\.delegate.selectedWorktreeChanged) 1353 1442 } ··· 1363 1452 await store.send(.selectNextWorktree) 1364 1453 await store.receive(\.selectWorktree) { 1365 1454 $0.selection = .worktree(wt1.id) 1455 + $0.sidebarSelectedWorktreeIDs = [wt1.id] 1456 + } 1457 + await store.receive(\.delegate.selectedWorktreeChanged) 1458 + } 1459 + 1460 + @Test func selectNextWorktreeCollapsesSidebarSelectionToSingleWorktree() async { 1461 + let wt1 = makeWorktree(id: "/tmp/wt1", name: "alpha") 1462 + let wt2 = makeWorktree(id: "/tmp/wt2", name: "beta") 1463 + let wt3 = makeWorktree(id: "/tmp/wt3", name: "gamma") 1464 + let repository = makeRepository(id: "/tmp/repo", worktrees: [wt1, wt2, wt3]) 1465 + var state = makeState(repositories: [repository]) 1466 + state.selection = .worktree(wt1.id) 1467 + state.sidebarSelectedWorktreeIDs = [wt1.id, wt3.id] 1468 + let store = TestStore(initialState: state) { 1469 + RepositoriesFeature() 1470 + } 1471 + 1472 + await store.send(.selectNextWorktree) 1473 + await store.receive(\.selectWorktree) { 1474 + $0.selection = .worktree(wt2.id) 1475 + $0.sidebarSelectedWorktreeIDs = [wt2.id] 1366 1476 } 1367 1477 await store.receive(\.delegate.selectedWorktreeChanged) 1368 1478 } ··· 1378 1488 await store.send(.selectPreviousWorktree) 1379 1489 await store.receive(\.selectWorktree) { 1380 1490 $0.selection = .worktree(wt2.id) 1491 + $0.sidebarSelectedWorktreeIDs = [wt2.id] 1381 1492 } 1382 1493 await store.receive(\.delegate.selectedWorktreeChanged) 1383 1494 } ··· 1400 1511 } 1401 1512 1402 1513 await store.send(.selectNextWorktree) 1403 - await store.receive(\.selectWorktree) 1514 + await store.receive(\.selectWorktree) { 1515 + $0.selection = .worktree(worktree.id) 1516 + $0.sidebarSelectedWorktreeIDs = [worktree.id] 1517 + } 1404 1518 await store.receive(\.delegate.selectedWorktreeChanged) 1405 1519 } 1406 1520