native macOS codings agent orchestrator
6
fork

Configure Feed

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

Refactor sidebar presentation drag handling

onevcat 2fbf8535 4ac50a7b

+672 -102
+196
supacode/Features/Repositories/Models/SidebarPresentation.swift
··· 1 + import Foundation 2 + 3 + struct SidebarPresentation: Equatable { 4 + var items: [SidebarItem] 5 + 6 + static func showsListHeader(repositoryCount: Int) -> Bool { 7 + repositoryCount > 10 8 + } 9 + 10 + var repositoryOrderIDs: [Repository.ID] { 11 + items.compactMap(\.repositoryOrderID) 12 + } 13 + 14 + func repositoryOrderAfterMove( 15 + fromOffsets source: IndexSet, 16 + toOffset destination: Int 17 + ) -> [Repository.ID] { 18 + var orderedIDs = repositoryOrderIDs 19 + orderedIDs.moveElements(fromOffsets: source, toOffset: destination) 20 + return orderedIDs 21 + } 22 + } 23 + 24 + enum SidebarItem: Equatable, Identifiable { 25 + case listHeader(SidebarListHeaderModel) 26 + case repository(SidebarRepositoryContainerModel) 27 + case failedRepository(FailedRepositoryModel) 28 + case archivedWorktrees(ArchivedWorktreesRowModel) 29 + 30 + var id: SidebarPresentationItemID { 31 + switch self { 32 + case .listHeader: 33 + return .listHeader 34 + case .repository(let model): 35 + return .repository(model.id) 36 + case .failedRepository(let model): 37 + return .failedRepository(model.id) 38 + case .archivedWorktrees: 39 + return .archivedWorktrees 40 + } 41 + } 42 + 43 + var repositoryOrderID: Repository.ID? { 44 + switch self { 45 + case .repository(let model): 46 + return model.id 47 + case .failedRepository(let model) where model.isReorderable: 48 + return model.id 49 + case .listHeader, .failedRepository, .archivedWorktrees: 50 + return nil 51 + } 52 + } 53 + } 54 + 55 + enum SidebarPresentationItemID: Equatable, Hashable { 56 + case listHeader 57 + case repository(Repository.ID) 58 + case failedRepository(Repository.ID) 59 + case archivedWorktrees 60 + } 61 + 62 + struct SidebarListHeaderModel: Equatable, Identifiable { 63 + let id = SidebarPresentationItemID.listHeader 64 + var repositoryCount: Int 65 + } 66 + 67 + struct SidebarRepositoryContainerModel: Equatable, Identifiable { 68 + var id: Repository.ID { repositoryID } 69 + 70 + var repositoryID: Repository.ID 71 + var title: String 72 + var rootURL: URL 73 + var kind: Repository.Kind 74 + var isExpanded: Bool 75 + var isRemoving: Bool 76 + var worktreeSections: WorktreeRowSections 77 + } 78 + 79 + struct FailedRepositoryModel: Equatable, Identifiable { 80 + var id: Repository.ID 81 + var name: String 82 + var path: String 83 + var failureMessage: String 84 + var isReorderable: Bool 85 + } 86 + 87 + struct ArchivedWorktreesRowModel: Equatable, Identifiable { 88 + let id = SidebarPresentationItemID.archivedWorktrees 89 + var count: Int 90 + } 91 + 92 + enum SidebarWorktreeSection: Equatable { 93 + case pinned 94 + case unpinned 95 + } 96 + 97 + struct SidebarWorktreeDropTarget: Equatable { 98 + var repositoryID: Repository.ID 99 + var section: SidebarWorktreeSection 100 + var source: IndexSet 101 + var destination: Int 102 + 103 + var action: RepositoriesFeature.WorktreeOrderingAction { 104 + switch section { 105 + case .pinned: 106 + return .pinnedWorktreesMoved(repositoryID: repositoryID, source, destination) 107 + case .unpinned: 108 + return .unpinnedWorktreesMoved(repositoryID: repositoryID, source, destination) 109 + } 110 + } 111 + } 112 + 113 + extension RepositoriesFeature.State { 114 + func sidebarPresentation( 115 + expandedRepositoryIDs: Set<Repository.ID>, 116 + includesArchivedWorktreesRow: Bool = false 117 + ) -> SidebarPresentation { 118 + let repositoriesByID = Dictionary(uniqueKeysWithValues: repositories.map { ($0.id, $0) }) 119 + let roots = sidebarPresentationRoots() 120 + let repositoryCount = roots.count 121 + var items: [SidebarItem] = [] 122 + 123 + if SidebarPresentation.showsListHeader(repositoryCount: repositoryCount) { 124 + items.append(.listHeader(SidebarListHeaderModel(repositoryCount: repositoryCount))) 125 + } 126 + 127 + for rootURL in roots { 128 + let standardizedRootURL = rootURL.standardizedFileURL 129 + let repositoryID = standardizedRootURL.path(percentEncoded: false) 130 + if let failureMessage = loadFailuresByID[repositoryID] { 131 + let path = standardizedRootURL.path(percentEncoded: false) 132 + items.append( 133 + .failedRepository( 134 + FailedRepositoryModel( 135 + id: repositoryID, 136 + name: Repository.name(for: standardizedRootURL), 137 + path: path, 138 + failureMessage: failureMessage, 139 + isReorderable: true 140 + ) 141 + ) 142 + ) 143 + } else if let repository = repositoriesByID[repositoryID] { 144 + let isExpanded = expandedRepositoryIDs.contains(repository.id) 145 + items.append( 146 + .repository( 147 + SidebarRepositoryContainerModel( 148 + repositoryID: repository.id, 149 + title: repository.name, 150 + rootURL: repository.rootURL, 151 + kind: repository.kind, 152 + isExpanded: isExpanded, 153 + isRemoving: isRemovingRepository(repository), 154 + worktreeSections: isExpanded ? worktreeRowSections(in: repository) : .empty 155 + ) 156 + ) 157 + ) 158 + } 159 + } 160 + 161 + if includesArchivedWorktreesRow, !archivedWorktrees.isEmpty { 162 + items.append(.archivedWorktrees(ArchivedWorktreesRowModel(count: archivedWorktrees.count))) 163 + } 164 + 165 + return SidebarPresentation(items: items) 166 + } 167 + 168 + private func sidebarPresentationRoots() -> [URL] { 169 + let orderedRoots = orderedRepositoryRoots() 170 + if !orderedRoots.isEmpty { 171 + return orderedRoots 172 + } 173 + return repositories.map(\.rootURL) 174 + } 175 + } 176 + 177 + extension WorktreeRowSections { 178 + static let empty = WorktreeRowSections( 179 + main: nil, 180 + pinned: [], 181 + pending: [], 182 + unpinned: [] 183 + ) 184 + } 185 + 186 + extension Array { 187 + fileprivate mutating func moveElements(fromOffsets source: IndexSet, toOffset destination: Int) { 188 + let sourceIndexes = source.sorted() 189 + let movedElements = sourceIndexes.map { self[$0] } 190 + for index in sourceIndexes.reversed() { 191 + remove(at: index) 192 + } 193 + let removedBeforeDestination = sourceIndexes.filter { $0 < destination }.count 194 + insert(contentsOf: movedElements, at: destination - removedBeforeDestination) 195 + } 196 + }
+78 -21
supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeOrdering.swift
··· 142 142 return .merge(effects) 143 143 144 144 case .worktreeNotificationReceived(let worktreeID): 145 - guard let repositoryID = state.repositoryID(containing: worktreeID), 146 - let repository = state.repositories[id: repositoryID], 147 - let worktree = repository.worktrees[id: worktreeID] 148 - else { 145 + guard notificationReorderTarget(for: worktreeID, state: state) != nil else { 146 + return .none 147 + } 148 + if state.isSidebarDragActive { 149 + state.pendingSidebarNotifyReorderIDs.removeAll { $0 == worktreeID } 150 + state.pendingSidebarNotifyReorderIDs.append(worktreeID) 149 151 return .none 150 152 } 151 - if state.isWorktreeArchived(worktree.id) { 153 + guard applyNotificationReorder(for: worktreeID, state: &state, animated: true) else { 152 154 return .none 153 155 } 154 - if state.moveNotifiedWorktreeToTop, !state.isMainWorktree(worktree), !state.isWorktreePinned(worktree) { 155 - let reordered = reorderedUnpinnedWorktreeIDs( 156 - for: worktreeID, 157 - in: repository, 158 - state: state 159 - ) 160 - if state.worktreeOrderByRepository[repositoryID] != reordered { 161 - withAnimation(.snappy(duration: 0.2)) { 162 - state.worktreeOrderByRepository[repositoryID] = reordered 163 - } 164 - let worktreeOrderByRepository = state.worktreeOrderByRepository 165 - return .run { _ in 166 - await repositoryPersistence.saveWorktreeOrderByRepository(worktreeOrderByRepository) 167 - } 168 - } 156 + let worktreeOrderByRepository = state.worktreeOrderByRepository 157 + return .run { _ in 158 + await repositoryPersistence.saveWorktreeOrderByRepository(worktreeOrderByRepository) 159 + } 160 + 161 + case .setSidebarDragActive(let isActive): 162 + guard state.isSidebarDragActive != isActive else { 163 + return .none 164 + } 165 + state.isSidebarDragActive = isActive 166 + guard !isActive else { 167 + return .none 168 + } 169 + let pendingWorktreeIDs = state.pendingSidebarNotifyReorderIDs 170 + state.pendingSidebarNotifyReorderIDs = [] 171 + guard !pendingWorktreeIDs.isEmpty else { 172 + return .none 173 + } 174 + var didReorder = false 175 + for worktreeID in pendingWorktreeIDs { 176 + didReorder = applyNotificationReorder(for: worktreeID, state: &state, animated: false) || didReorder 177 + } 178 + guard didReorder else { 179 + return .none 180 + } 181 + let worktreeOrderByRepository = state.worktreeOrderByRepository 182 + return .run { _ in 183 + await repositoryPersistence.saveWorktreeOrderByRepository(worktreeOrderByRepository) 169 184 } 170 - return .none 171 185 172 186 case .setMoveNotifiedWorktreeToTop(let isEnabled): 173 187 state.moveNotifiedWorktreeToTop = isEnabled ··· 184 198 } 185 199 } 186 200 } 201 + 202 + private func notificationReorderTarget( 203 + for worktreeID: Worktree.ID, 204 + state: RepositoriesFeature.State 205 + ) -> (repositoryID: Repository.ID, repository: Repository)? { 206 + guard state.moveNotifiedWorktreeToTop, 207 + let repositoryID = state.repositoryID(containing: worktreeID), 208 + let repository = state.repositories[id: repositoryID], 209 + let worktree = repository.worktrees[id: worktreeID], 210 + !state.isWorktreeArchived(worktree.id), 211 + !state.isMainWorktree(worktree), 212 + !state.isWorktreePinned(worktree) 213 + else { 214 + return nil 215 + } 216 + return (repositoryID, repository) 217 + } 218 + 219 + private func applyNotificationReorder( 220 + for worktreeID: Worktree.ID, 221 + state: inout RepositoriesFeature.State, 222 + animated: Bool 223 + ) -> Bool { 224 + guard let target = notificationReorderTarget(for: worktreeID, state: state) else { 225 + return false 226 + } 227 + let reordered = reorderedUnpinnedWorktreeIDs( 228 + for: worktreeID, 229 + in: target.repository, 230 + state: state 231 + ) 232 + guard state.worktreeOrderByRepository[target.repositoryID] != reordered else { 233 + return false 234 + } 235 + if animated { 236 + withAnimation(.snappy(duration: 0.2)) { 237 + state.worktreeOrderByRepository[target.repositoryID] = reordered 238 + } 239 + } else { 240 + state.worktreeOrderByRepository[target.repositoryID] = reordered 241 + } 242 + return true 243 + }
+4 -1
supacode/Features/Repositories/Reducer/RepositoriesFeature.swift
··· 146 146 case pinWorktree(Worktree.ID) 147 147 case unpinWorktree(Worktree.ID) 148 148 case worktreeNotificationReceived(Worktree.ID) 149 + case setSidebarDragActive(Bool) 149 150 case setMoveNotifiedWorktreeToTop(Bool) 150 151 } 151 152 ··· 235 236 var sidebarSelectedWorktreeIDs: Set<Worktree.ID> = [] 236 237 var nextPendingSidebarRevealID = 0 237 238 var pendingSidebarReveal: PendingSidebarReveal? 239 + var isSidebarDragActive = false 240 + var pendingSidebarNotifyReorderIDs: [Worktree.ID] = [] 238 241 @Shared(.appStorage("sidebarCollapsedRepositoryIDs")) var collapsedRepositoryIDs: [Repository.ID] = [] 239 242 @Presents var worktreeCreationPrompt: WorktreeCreationPromptFeature.State? 240 243 @Presents var alert: AlertState<Alert>? ··· 1977 1980 } 1978 1981 } 1979 1982 1980 - struct WorktreeRowSections { 1983 + struct WorktreeRowSections: Equatable { 1981 1984 let main: WorktreeRowModel? 1982 1985 let pinned: [WorktreeRowModel] 1983 1986 let pending: [WorktreeRowModel]
+79 -74
supacode/Features/Repositories/Views/SidebarListView.swift
··· 39 39 var body: some View { 40 40 let state = store.state 41 41 let hotkeyRows = state.orderedWorktreeRows(includingRepositoryIDs: expandedRepoIDs) 42 - let orderedRoots = state.orderedRepositoryRoots() 42 + let presentation = state.sidebarPresentation(expandedRepositoryIDs: expandedRepoIDs) 43 43 let expandableRepositoryIDs = Self.expandableRepositoryIDs(in: state.repositories) 44 44 let repositoryListHeaderAction = Self.repositoryListHeaderAction( 45 45 expandedRepoIDs: expandedRepoIDs, 46 46 expandableRepositoryIDs: expandableRepositoryIDs 47 47 ) 48 - let visibleRepositoryCount = orderedRoots.isEmpty ? state.repositories.count : orderedRoots.count 49 - let showsRepositoryListHeader = Self.showsRepositoryListHeader(repositoryCount: visibleRepositoryCount) 48 + let repositoryItems = presentation.items.filter(\.isRepositoryOrderItem) 49 + let showsRepositoryListHeader = presentation.items.contains { item in 50 + if case .listHeader = item { 51 + return true 52 + } 53 + return false 54 + } 50 55 let selectedWorktreeIDs = Set(sidebarSelections.compactMap(\.worktreeID)) 51 56 let selection = Binding<Set<SidebarSelection>>( 52 57 get: { ··· 131 136 } 132 137 } 133 138 ) 134 - let repositoriesByID = Dictionary(uniqueKeysWithValues: store.repositories.map { ($0.id, $0) }) 135 139 let pendingSidebarReveal = state.pendingSidebarReveal 136 140 137 141 ScrollViewReader { scrollProxy in ··· 144 148 .listRowInsets(EdgeInsets()) 145 149 } 146 150 147 - if orderedRoots.isEmpty { 148 - let repositories = store.repositories 149 - ForEach(Array(repositories.enumerated()), id: \.element.id) { index, repository in 150 - RepositorySectionView( 151 - repository: repository, 152 - hasTopSpacing: index > 0, 153 - isDragActive: isDragActive, 154 - hotkeyRows: hotkeyRows, 155 - selectedWorktreeIDs: selectedWorktreeIDs, 156 - expandedRepoIDs: $expandedRepoIDs, 157 - store: store, 158 - terminalManager: terminalManager 159 - ) 160 - .listRowInsets(EdgeInsets()) 161 - } 162 - } else { 163 - let orderedRows = Array(orderedRoots.enumerated()).map { index, rootURL in 164 - ( 165 - index: index, 166 - rootURL: rootURL, 167 - repositoryID: rootURL.standardizedFileURL.path(percentEncoded: false) 168 - ) 169 - } 170 - ForEach(orderedRows, id: \.repositoryID) { row in 171 - let index = row.index 172 - let rootURL = row.rootURL 173 - let repositoryID = row.repositoryID 174 - if let failureMessage = state.loadFailuresByID[repositoryID] { 175 - let name = Repository.name(for: rootURL.standardizedFileURL) 176 - let path = rootURL.standardizedFileURL.path(percentEncoded: false) 177 - FailedRepositoryRow( 178 - name: name, 179 - path: path, 180 - showFailure: { 181 - let message = "\(path)\n\n\(failureMessage)" 182 - store.send(.presentAlert(title: "Unable to load \(name)", message: message)) 183 - }, 184 - removeRepository: { 185 - store.send(.repositoryManagement(.removeFailedRepository(repositoryID))) 186 - } 187 - ) 188 - .padding(.horizontal, 12) 189 - .overlay(alignment: .top) { 190 - if index > 0 { 191 - Rectangle() 192 - .fill(.secondary) 193 - .frame(height: 1) 194 - .frame(maxWidth: .infinity) 195 - .accessibilityHidden(true) 196 - } 197 - } 198 - .listRowInsets(EdgeInsets()) 199 - } else if let repository = repositoriesByID[repositoryID] { 200 - RepositorySectionView( 201 - repository: repository, 202 - hasTopSpacing: index > 0, 203 - isDragActive: isDragActive, 204 - hotkeyRows: hotkeyRows, 205 - selectedWorktreeIDs: selectedWorktreeIDs, 206 - expandedRepoIDs: $expandedRepoIDs, 207 - store: store, 208 - terminalManager: terminalManager 209 - ) 210 - .listRowInsets(EdgeInsets()) 211 - } 212 - } 213 - .onMove { offsets, destination in 214 - store.send(.worktreeOrdering(.repositoriesMoved(offsets, destination))) 215 - } 151 + ForEach(Array(repositoryItems.enumerated()), id: \.element.id) { index, item in 152 + repositoryItemView( 153 + item, 154 + index: index, 155 + hotkeyRows: hotkeyRows, 156 + selectedWorktreeIDs: selectedWorktreeIDs 157 + ) 158 + .listRowInsets(EdgeInsets()) 159 + } 160 + .onMove { offsets, destination in 161 + store.send(.worktreeOrdering(.repositoriesMoved(offsets, destination))) 216 162 } 217 163 } 218 164 .listStyle(.sidebar) ··· 222 168 if case .ended = session.phase { 223 169 if isDragActive { 224 170 isDragActive = false 171 + store.send(.worktreeOrdering(.setSidebarDragActive(false))) 225 172 } 226 173 return 227 174 } 228 175 if case .dataTransferCompleted = session.phase { 229 176 if isDragActive { 230 177 isDragActive = false 178 + store.send(.worktreeOrdering(.setSidebarDragActive(false))) 231 179 } 232 180 return 233 181 } 234 182 if !isDragActive { 235 183 isDragActive = true 184 + store.send(.worktreeOrdering(.setSidebarDragActive(true))) 236 185 } 237 186 } 238 187 .safeAreaInset(edge: .top) { ··· 318 267 .padding(.bottom, 4) 319 268 } 320 269 270 + @ViewBuilder 271 + private func repositoryItemView( 272 + _ item: SidebarItem, 273 + index: Int, 274 + hotkeyRows: [WorktreeRowModel], 275 + selectedWorktreeIDs: Set<Worktree.ID> 276 + ) -> some View { 277 + switch item { 278 + case .repository(let model): 279 + if let repository = store.state.repositories[id: model.repositoryID] { 280 + RepositorySectionView( 281 + repository: repository, 282 + hasTopSpacing: index > 0, 283 + isDragActive: isDragActive, 284 + hotkeyRows: hotkeyRows, 285 + selectedWorktreeIDs: selectedWorktreeIDs, 286 + expandedRepoIDs: $expandedRepoIDs, 287 + store: store, 288 + terminalManager: terminalManager 289 + ) 290 + } 291 + 292 + case .failedRepository(let model): 293 + FailedRepositoryRow( 294 + name: model.name, 295 + path: model.path, 296 + showFailure: { 297 + let message = "\(model.path)\n\n\(model.failureMessage)" 298 + store.send(.presentAlert(title: "Unable to load \(model.name)", message: message)) 299 + }, 300 + removeRepository: { 301 + store.send(.repositoryManagement(.removeFailedRepository(model.id))) 302 + } 303 + ) 304 + .padding(.horizontal, 12) 305 + .overlay(alignment: .top) { 306 + if index > 0 { 307 + Rectangle() 308 + .fill(.secondary) 309 + .frame(height: 1) 310 + .frame(maxWidth: .infinity) 311 + .accessibilityHidden(true) 312 + } 313 + } 314 + 315 + case .listHeader, .archivedWorktrees: 316 + EmptyView() 317 + } 318 + } 319 + 321 320 @MainActor 322 321 private func revealPendingSidebarWorktree( 323 322 _ pendingSidebarReveal: PendingSidebarReveal?, ··· 354 353 } 355 354 356 355 static func showsRepositoryListHeader(repositoryCount: Int) -> Bool { 357 - repositoryCount > 10 356 + SidebarPresentation.showsListHeader(repositoryCount: repositoryCount) 357 + } 358 + } 359 + 360 + extension SidebarItem { 361 + fileprivate var isRepositoryOrderItem: Bool { 362 + repositoryOrderID != nil 358 363 } 359 364 } 360 365
+9 -6
supacode/Features/Repositories/Views/WorktreeRowsView.swift
··· 25 25 let state = store.state 26 26 let sections = state.worktreeRowSections(in: repository) 27 27 let isRepositoryRemoving = state.isRemovingRepository(repository) 28 + let isSidebarDragActive = state.isSidebarDragActive 28 29 let showShortcutHints = commandKeyObserver.isPressed 29 30 let allRows = showShortcutHints ? hotkeyRows : [] 30 31 let shortcutIndexByID = Dictionary( ··· 37 38 showShortcutHints: showShortcutHints, 38 39 shortcutIndexByID: shortcutIndexByID 39 40 ) 40 - .animation(.easeOut(duration: 0.2), value: rowIDs) 41 + .animation(isSidebarDragActive ? nil : .easeOut(duration: 0.2), value: rowIDs) 41 42 } 42 43 43 44 @ViewBuilder ··· 94 95 moveDisabled: Bool, 95 96 shortcutHint: String? 96 97 ) -> some View { 98 + let isSidebarDragActive = store.state.isSidebarDragActive 97 99 let showsNotificationIndicator = terminalManager.hasUnseenNotifications(for: row.id) 98 100 let displayName = 99 101 if row.isDeleting { ··· 103 105 } else { 104 106 row.name 105 107 } 106 - let canShowRowActions = row.isRemovable && !isRepositoryRemoving 108 + let canShowRowActions = row.isRemovable && !isRepositoryRemoving && !isSidebarDragActive 107 109 let pinAction: (() -> Void)? = 108 110 canShowRowActions && !row.isMainWorktree 109 111 ? { togglePin(for: row.id, isPinned: row.isPinned) } ··· 134 136 let config = WorktreeRowViewConfig( 135 137 displayName: displayName, 136 138 worktreeName: worktreeName(for: row), 137 - isHovered: hoveredWorktreeID == row.id, 138 - showsNotificationIndicator: showsNotificationIndicator, 139 - notifications: notifications, 139 + isHovered: !isSidebarDragActive && hoveredWorktreeID == row.id, 140 + showsNotificationIndicator: !isSidebarDragActive && showsNotificationIndicator, 141 + notifications: isSidebarDragActive ? [] : notifications, 140 142 onFocusNotification: onFocusNotification, 141 143 shortcutHint: shortcutHint, 142 144 pinAction: pinAction, ··· 205 207 let isSelected = selectedWorktreeIDs.contains(row.id) 206 208 let taskStatus = terminalManager.taskStatus(for: row.id) 207 209 let isRunScriptRunning = terminalManager.isRunScriptRunning(for: row.id) 210 + let isSidebarDragActive = store.state.isSidebarDragActive 208 211 return WorktreeRow( 209 212 name: config.displayName, 210 213 worktreeName: config.worktreeName, 211 214 info: row.info, 212 - showsPullRequestInfo: !draggingWorktreeIDs.contains(row.id), 215 + showsPullRequestInfo: !isSidebarDragActive && !draggingWorktreeIDs.contains(row.id), 213 216 isHovered: config.isHovered, 214 217 isPinned: row.isPinned, 215 218 isMainWorktree: row.isMainWorktree,
+109
supacodeTests/RepositoriesFeatureTests.swift
··· 2577 2577 #expect(store.state.statusToast == nil) 2578 2578 } 2579 2579 2580 + @Test func worktreeNotificationDuringSidebarDragDefersReorder() async { 2581 + let repoRoot = "/tmp/repo" 2582 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 2583 + let featureA = makeWorktree(id: "/tmp/repo/a", name: "a", repoRoot: repoRoot) 2584 + let featureB = makeWorktree(id: "/tmp/repo/b", name: "b", repoRoot: repoRoot) 2585 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureA, featureB]) 2586 + var state = makeState(repositories: [repository]) 2587 + state.worktreeOrderByRepository[repoRoot] = [featureA.id, featureB.id] 2588 + let store = TestStore(initialState: state) { 2589 + RepositoriesFeature() 2590 + } 2591 + 2592 + await store.send(.worktreeOrdering(.setSidebarDragActive(true))) { 2593 + $0.isSidebarDragActive = true 2594 + } 2595 + await store.send(.worktreeOrdering(.worktreeNotificationReceived(featureB.id))) { 2596 + $0.pendingSidebarNotifyReorderIDs = [featureB.id] 2597 + } 2598 + #expect(store.state.worktreeOrderByRepository[repoRoot] == [featureA.id, featureB.id]) 2599 + } 2600 + 2601 + @Test func endingSidebarDragAppliesPendingNotificationReordersInOrder() async { 2602 + let repoRoot = "/tmp/repo" 2603 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 2604 + let featureA = makeWorktree(id: "/tmp/repo/a", name: "a", repoRoot: repoRoot) 2605 + let featureB = makeWorktree(id: "/tmp/repo/b", name: "b", repoRoot: repoRoot) 2606 + let featureC = makeWorktree(id: "/tmp/repo/c", name: "c", repoRoot: repoRoot) 2607 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureA, featureB, featureC]) 2608 + var state = makeState(repositories: [repository]) 2609 + state.isSidebarDragActive = true 2610 + state.pendingSidebarNotifyReorderIDs = [featureA.id, featureC.id, featureB.id] 2611 + state.worktreeOrderByRepository[repoRoot] = [featureA.id, featureB.id, featureC.id] 2612 + let store = TestStore(initialState: state) { 2613 + RepositoriesFeature() 2614 + } 2615 + 2616 + await store.send(.worktreeOrdering(.setSidebarDragActive(false))) { 2617 + $0.isSidebarDragActive = false 2618 + $0.pendingSidebarNotifyReorderIDs = [] 2619 + $0.worktreeOrderByRepository[repoRoot] = [featureB.id, featureC.id, featureA.id] 2620 + } 2621 + } 2622 + 2623 + @Test func repeatedNotificationDuringSidebarDragKeepsLatestPosition() async { 2624 + let repoRoot = "/tmp/repo" 2625 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 2626 + let featureA = makeWorktree(id: "/tmp/repo/a", name: "a", repoRoot: repoRoot) 2627 + let featureB = makeWorktree(id: "/tmp/repo/b", name: "b", repoRoot: repoRoot) 2628 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureA, featureB]) 2629 + var state = makeState(repositories: [repository]) 2630 + state.isSidebarDragActive = true 2631 + state.worktreeOrderByRepository[repoRoot] = [featureA.id, featureB.id] 2632 + let store = TestStore(initialState: state) { 2633 + RepositoriesFeature() 2634 + } 2635 + 2636 + await store.send(.worktreeOrdering(.worktreeNotificationReceived(featureA.id))) { 2637 + $0.pendingSidebarNotifyReorderIDs = [featureA.id] 2638 + } 2639 + await store.send(.worktreeOrdering(.worktreeNotificationReceived(featureB.id))) { 2640 + $0.pendingSidebarNotifyReorderIDs = [featureA.id, featureB.id] 2641 + } 2642 + await store.send(.worktreeOrdering(.worktreeNotificationReceived(featureA.id))) { 2643 + $0.pendingSidebarNotifyReorderIDs = [featureB.id, featureA.id] 2644 + } 2645 + await store.send(.worktreeOrdering(.setSidebarDragActive(false))) { 2646 + $0.isSidebarDragActive = false 2647 + $0.pendingSidebarNotifyReorderIDs = [] 2648 + $0.worktreeOrderByRepository[repoRoot] = [featureA.id, featureB.id] 2649 + } 2650 + } 2651 + 2652 + @Test func stalePendingNotificationReordersAreIgnoredWhenDragEnds() async { 2653 + let repoRoot = "/tmp/repo" 2654 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 2655 + let featureA = makeWorktree(id: "/tmp/repo/a", name: "a", repoRoot: repoRoot) 2656 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureA]) 2657 + var state = makeState(repositories: [repository]) 2658 + state.isSidebarDragActive = true 2659 + state.pendingSidebarNotifyReorderIDs = ["/tmp/repo/stale", featureA.id] 2660 + state.worktreeOrderByRepository[repoRoot] = [featureA.id] 2661 + let store = TestStore(initialState: state) { 2662 + RepositoriesFeature() 2663 + } 2664 + 2665 + await store.send(.worktreeOrdering(.setSidebarDragActive(false))) { 2666 + $0.isSidebarDragActive = false 2667 + $0.pendingSidebarNotifyReorderIDs = [] 2668 + } 2669 + } 2670 + 2671 + @Test func notificationDuringSidebarDragDoesNotRecordWhenMoveToTopDisabled() async { 2672 + let repoRoot = "/tmp/repo" 2673 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 2674 + let featureA = makeWorktree(id: "/tmp/repo/a", name: "a", repoRoot: repoRoot) 2675 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureA]) 2676 + var state = makeState(repositories: [repository]) 2677 + state.isSidebarDragActive = true 2678 + state.moveNotifiedWorktreeToTop = false 2679 + state.worktreeOrderByRepository[repoRoot] = [featureA.id] 2680 + let store = TestStore(initialState: state) { 2681 + RepositoriesFeature() 2682 + } 2683 + 2684 + await store.send(.worktreeOrdering(.worktreeNotificationReceived(featureA.id))) 2685 + #expect(store.state.pendingSidebarNotifyReorderIDs.isEmpty) 2686 + #expect(store.state.worktreeOrderByRepository[repoRoot] == [featureA.id]) 2687 + } 2688 + 2580 2689 @Test func setMoveNotifiedWorktreeToTopUpdatesState() async { 2581 2690 var state = makeState(repositories: []) 2582 2691 state.moveNotifiedWorktreeToTop = true
+197
supacodeTests/SidebarPresentationTests.swift
··· 1 + import Foundation 2 + import IdentifiedCollections 3 + import Testing 4 + 5 + @testable import supacode 6 + 7 + @MainActor 8 + struct SidebarPresentationTests { 9 + @Test func expandedRepositoryIsOneOuterItemWithChildRows() { 10 + let repoRoot = "/tmp/repo" 11 + let main = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 12 + let feature = makeWorktree(id: "/tmp/repo/feature", name: "feature", repoRoot: repoRoot) 13 + let repository = makeRepository(id: repoRoot, worktrees: [main, feature]) 14 + let state = makeState(repositories: [repository]) 15 + 16 + let presentation = state.sidebarPresentation(expandedRepositoryIDs: [repository.id]) 17 + 18 + #expect(presentation.items.count == 1) 19 + guard case .repository(let model) = presentation.items.first else { 20 + Issue.record("Expected repository container") 21 + return 22 + } 23 + #expect(model.id == repository.id) 24 + #expect(model.isExpanded) 25 + #expect(model.worktreeSections.allRows.map(\.id) == [main.id, feature.id]) 26 + } 27 + 28 + @Test func collapsedRepositoryKeepsContainerButHidesChildRows() { 29 + let repoRoot = "/tmp/repo" 30 + let main = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 31 + let feature = makeWorktree(id: "/tmp/repo/feature", name: "feature", repoRoot: repoRoot) 32 + let repository = makeRepository(id: repoRoot, worktrees: [main, feature]) 33 + let state = makeState(repositories: [repository]) 34 + 35 + let presentation = state.sidebarPresentation(expandedRepositoryIDs: []) 36 + 37 + guard case .repository(let model) = presentation.items.first else { 38 + Issue.record("Expected repository container") 39 + return 40 + } 41 + #expect(!model.isExpanded) 42 + #expect(model.worktreeSections.allRows.isEmpty) 43 + } 44 + 45 + @Test func failedRepositoriesParticipateInRootOrder() { 46 + let repoA = makeRepository(id: "/tmp/a", worktrees: []) 47 + var state = makeState(repositories: [repoA]) 48 + state.repositoryRoots = [ 49 + URL(fileURLWithPath: "/tmp/missing"), 50 + repoA.rootURL, 51 + ] 52 + state.repositoryOrderIDs = ["/tmp/missing", repoA.id] 53 + state.loadFailuresByID["/tmp/missing"] = "missing" 54 + 55 + let presentation = state.sidebarPresentation(expandedRepositoryIDs: [repoA.id]) 56 + 57 + #expect(presentation.repositoryOrderIDs == ["/tmp/missing", repoA.id]) 58 + #expect( 59 + presentation.repositoryOrderAfterMove(fromOffsets: IndexSet(integer: 0), toOffset: 2) == [ 60 + repoA.id, "/tmp/missing", 61 + ]) 62 + guard case .failedRepository(let failed) = presentation.items.first else { 63 + Issue.record("Expected failed repository first") 64 + return 65 + } 66 + #expect(failed.id == "/tmp/missing") 67 + #expect(failed.isReorderable) 68 + } 69 + 70 + @Test func plainFolderProducesContainerWithoutWorktreeChildren() { 71 + let repository = makeRepository(id: "/tmp/plain", kind: .plain, worktrees: []) 72 + let state = makeState(repositories: [repository]) 73 + 74 + let presentation = state.sidebarPresentation(expandedRepositoryIDs: [repository.id]) 75 + 76 + guard case .repository(let model) = presentation.items.first else { 77 + Issue.record("Expected repository container") 78 + return 79 + } 80 + #expect(model.kind == .plain) 81 + #expect(model.worktreeSections.allRows.isEmpty) 82 + } 83 + 84 + @Test func worktreeSectionsPreservePinnedMainPendingAndUnpinnedRows() { 85 + let repoRoot = "/tmp/repo" 86 + let main = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 87 + let pinned = makeWorktree(id: "/tmp/repo/pinned", name: "pinned", repoRoot: repoRoot) 88 + let unpinned = makeWorktree(id: "/tmp/repo/unpinned", name: "unpinned", repoRoot: repoRoot) 89 + let repository = makeRepository(id: repoRoot, worktrees: [main, pinned, unpinned]) 90 + var state = makeState(repositories: [repository]) 91 + state.pinnedWorktreeIDs = [pinned.id] 92 + state.pendingWorktrees = [ 93 + PendingWorktree( 94 + id: "/tmp/repo/pending", 95 + repositoryID: repository.id, 96 + progress: WorktreeCreationProgress(stage: .choosingWorktreeName, worktreeName: "pending") 97 + ) 98 + ] 99 + state.worktreeOrderByRepository[repository.id] = [unpinned.id] 100 + 101 + let presentation = state.sidebarPresentation(expandedRepositoryIDs: [repository.id]) 102 + 103 + guard case .repository(let model) = presentation.items.first else { 104 + Issue.record("Expected repository container") 105 + return 106 + } 107 + #expect(model.worktreeSections.main?.id == main.id) 108 + #expect(model.worktreeSections.pinned.map(\.id) == [pinned.id]) 109 + #expect(model.worktreeSections.pending.map(\.id) == ["/tmp/repo/pending"]) 110 + #expect(model.worktreeSections.unpinned.map(\.id) == [unpinned.id]) 111 + } 112 + 113 + @Test func emptyOrderedRootsStillBuildsRepositoryPresentation() { 114 + let repoA = makeRepository(id: "/tmp/a", worktrees: []) 115 + let repoB = makeRepository(id: "/tmp/b", worktrees: []) 116 + var state = RepositoriesFeature.State() 117 + state.repositories = [repoA, repoB] 118 + 119 + let presentation = state.sidebarPresentation(expandedRepositoryIDs: [repoA.id, repoB.id]) 120 + 121 + #expect(presentation.repositoryOrderIDs == [repoA.id, repoB.id]) 122 + } 123 + 124 + @Test func customOrderedRootsUseSamePresentationRules() { 125 + let repoA = makeRepository(id: "/tmp/a", worktrees: []) 126 + let repoB = makeRepository(id: "/tmp/b", worktrees: []) 127 + var state = makeState(repositories: [repoA, repoB]) 128 + state.repositoryOrderIDs = [repoB.id, repoA.id] 129 + 130 + let presentation = state.sidebarPresentation(expandedRepositoryIDs: [repoA.id, repoB.id]) 131 + 132 + #expect(presentation.repositoryOrderIDs == [repoB.id, repoA.id]) 133 + } 134 + 135 + @Test func worktreeDropDestinationsMapToExistingOrderingActions() { 136 + let pinned = SidebarWorktreeDropTarget( 137 + repositoryID: "/tmp/repo", 138 + section: .pinned, 139 + source: IndexSet(integer: 1), 140 + destination: 0 141 + ) 142 + let unpinned = SidebarWorktreeDropTarget( 143 + repositoryID: "/tmp/repo", 144 + section: .unpinned, 145 + source: IndexSet(integer: 0), 146 + destination: 2 147 + ) 148 + 149 + #expect( 150 + pinned.action 151 + == RepositoriesFeature.WorktreeOrderingAction.pinnedWorktreesMoved( 152 + repositoryID: "/tmp/repo", 153 + IndexSet(integer: 1), 154 + 0 155 + ) 156 + ) 157 + #expect( 158 + unpinned.action 159 + == RepositoriesFeature.WorktreeOrderingAction.unpinnedWorktreesMoved( 160 + repositoryID: "/tmp/repo", 161 + IndexSet(integer: 0), 162 + 2 163 + ) 164 + ) 165 + } 166 + 167 + private func makeWorktree(id: String, name: String, repoRoot: String) -> Worktree { 168 + Worktree( 169 + id: id, 170 + name: name, 171 + detail: "detail", 172 + workingDirectory: URL(fileURLWithPath: id), 173 + repositoryRootURL: URL(fileURLWithPath: repoRoot) 174 + ) 175 + } 176 + 177 + private func makeRepository( 178 + id: String, 179 + kind: Repository.Kind = .git, 180 + worktrees: [Worktree] 181 + ) -> Repository { 182 + Repository( 183 + id: id, 184 + rootURL: URL(fileURLWithPath: id), 185 + name: URL(fileURLWithPath: id).lastPathComponent, 186 + kind: kind, 187 + worktrees: IdentifiedArray(uniqueElements: worktrees) 188 + ) 189 + } 190 + 191 + private func makeState(repositories: [Repository]) -> RepositoriesFeature.State { 192 + var state = RepositoriesFeature.State() 193 + state.repositories = IdentifiedArray(uniqueElements: repositories) 194 + state.repositoryRoots = repositories.map(\.rootURL) 195 + return state 196 + } 197 + }