native macOS codings agent orchestrator
6
fork

Configure Feed

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

Fix sidebar drag indicators and row hit areas

onevcat 971a98ea bad7a46f

+261 -89
+2
supacode/Features/Repositories/Views/RepoDisplayName.swift
··· 16 16 17 17 var body: some View { 18 18 Text(customTitle ?? fallbackName) 19 + .lineLimit(1) 20 + .truncationMode(.tail) 19 21 .help(tooltip ?? "") 20 22 } 21 23 }
+118 -28
supacode/Features/Repositories/Views/SidebarDragSupport.swift
··· 27 27 28 28 private nonisolated static func itemProvider(payload: String) -> NSItemProvider { 29 29 let provider = NSItemProvider() 30 - let loadHandler: (@escaping (Data?, (any Error)?) -> Void) -> Progress? = { completion in 30 + let loadHandler: @Sendable (@escaping @Sendable (Data?, (any Error)?) -> Void) -> Progress? = { completion in 31 31 completion(Data(payload.utf8), nil) 32 32 return nil 33 33 } ··· 161 161 } 162 162 } 163 163 164 + enum SidebarDropIndicatorEdge: Equatable { 165 + case none 166 + case top 167 + case bottom 168 + 169 + static func edge( 170 + targetedDestination: Int?, 171 + rowIndex: Int, 172 + rowCount: Int 173 + ) -> Self { 174 + guard let targetedDestination else { 175 + return .none 176 + } 177 + if targetedDestination == rowIndex { 178 + return .top 179 + } 180 + if rowIndex == rowCount - 1, targetedDestination == rowCount { 181 + return .bottom 182 + } 183 + return .none 184 + } 185 + } 186 + 187 + struct SidebarDropTargetActions { 188 + let onDrop: (IndexSet, Int) -> Void 189 + let onDragEnded: () -> Void 190 + } 191 + 164 192 extension View { 165 193 func repositoryDropTarget( 166 194 index: Int, 167 195 repositoryOrderIDs: [Repository.ID], 196 + isEnabled: Bool, 168 197 targetedDestination: Binding<Int?>, 169 - onDrop: @escaping (IndexSet, Int) -> Void, 170 - onDragEnded: @escaping () -> Void 198 + actions: SidebarDropTargetActions 171 199 ) -> some View { 172 - self 200 + let edge = SidebarDropIndicatorEdge.edge( 201 + targetedDestination: targetedDestination.wrappedValue, 202 + rowIndex: index, 203 + rowCount: repositoryOrderIDs.count 204 + ) 205 + return 206 + self 173 207 .overlay(alignment: .top) { 174 - SidebarDropIndicator(isVisible: targetedDestination.wrappedValue == index) 208 + SidebarDropIndicator(isVisible: edge == .top) 175 209 } 176 210 .overlay(alignment: .bottom) { 177 - SidebarDropIndicator(isVisible: targetedDestination.wrappedValue == index + 1) 211 + SidebarDropIndicator(isVisible: edge == .bottom) 178 212 } 179 - .onDrop( 180 - of: [.prowlSidebarDragPayload], 181 - delegate: SidebarRepositoryDropDelegate( 182 - destination: { info in 183 - info.location.y < 24 ? index : index + 1 184 - }, 213 + .modifier( 214 + SidebarRepositoryDropTargetModifier( 215 + isEnabled: isEnabled, 216 + index: index, 185 217 repositoryOrderIDs: repositoryOrderIDs, 186 218 targetedDestination: targetedDestination, 187 - onDrop: onDrop, 188 - onDragEnded: onDragEnded 219 + actions: actions 189 220 ) 190 221 ) 191 222 } ··· 193 224 func worktreeDropTarget( 194 225 index: Int, 195 226 rowIDs: [Worktree.ID], 227 + isEnabled: Bool, 196 228 targetedDestination: Binding<Int?>, 197 - onDrop: @escaping (IndexSet, Int) -> Void, 198 - onDragEnded: @escaping () -> Void 229 + actions: SidebarDropTargetActions 199 230 ) -> some View { 200 - self 231 + let edge = SidebarDropIndicatorEdge.edge( 232 + targetedDestination: targetedDestination.wrappedValue, 233 + rowIndex: index, 234 + rowCount: rowIDs.count 235 + ) 236 + return 237 + self 201 238 .overlay(alignment: .top) { 202 - SidebarDropIndicator(isVisible: targetedDestination.wrappedValue == index, horizontalPadding: 28) 239 + SidebarDropIndicator(isVisible: edge == .top, horizontalPadding: 28) 203 240 } 204 241 .overlay(alignment: .bottom) { 205 - SidebarDropIndicator(isVisible: targetedDestination.wrappedValue == index + 1, horizontalPadding: 28) 242 + SidebarDropIndicator(isVisible: edge == .bottom, horizontalPadding: 28) 206 243 } 207 - .onDrop( 208 - of: [.prowlSidebarDragPayload], 209 - delegate: SidebarWorktreeDropDelegate( 210 - destination: { info in 211 - info.location.y < 18 ? index : index + 1 212 - }, 213 - sectionIDs: rowIDs, 244 + .modifier( 245 + SidebarWorktreeDropTargetModifier( 246 + isEnabled: isEnabled, 247 + index: index, 248 + rowIDs: rowIDs, 214 249 targetedDestination: targetedDestination, 215 - onDrop: onDrop, 216 - onDragEnded: onDragEnded 250 + actions: actions 217 251 ) 218 252 ) 219 253 } ··· 250 284 } 251 285 } 252 286 } 287 + 288 + private struct SidebarRepositoryDropTargetModifier: ViewModifier { 289 + let isEnabled: Bool 290 + let index: Int 291 + let repositoryOrderIDs: [Repository.ID] 292 + @Binding var targetedDestination: Int? 293 + let actions: SidebarDropTargetActions 294 + 295 + @ViewBuilder 296 + func body(content: Content) -> some View { 297 + if isEnabled { 298 + content.onDrop( 299 + of: [.prowlSidebarDragPayload], 300 + delegate: SidebarRepositoryDropDelegate( 301 + destination: { info in 302 + info.location.y < 24 ? index : index + 1 303 + }, 304 + repositoryOrderIDs: repositoryOrderIDs, 305 + targetedDestination: $targetedDestination, 306 + onDrop: actions.onDrop, 307 + onDragEnded: actions.onDragEnded 308 + ) 309 + ) 310 + } else { 311 + content 312 + } 313 + } 314 + } 315 + 316 + private struct SidebarWorktreeDropTargetModifier: ViewModifier { 317 + let isEnabled: Bool 318 + let index: Int 319 + let rowIDs: [Worktree.ID] 320 + @Binding var targetedDestination: Int? 321 + let actions: SidebarDropTargetActions 322 + 323 + @ViewBuilder 324 + func body(content: Content) -> some View { 325 + if isEnabled { 326 + content.onDrop( 327 + of: [.prowlSidebarDragPayload], 328 + delegate: SidebarWorktreeDropDelegate( 329 + destination: { info in 330 + info.location.y < 18 ? index : index + 1 331 + }, 332 + sectionIDs: rowIDs, 333 + targetedDestination: $targetedDestination, 334 + onDrop: actions.onDrop, 335 + onDragEnded: actions.onDragEnded 336 + ) 337 + ) 338 + } else { 339 + content 340 + } 341 + } 342 + }
+7 -4
supacode/Features/Repositories/Views/SidebarListView.swift
··· 243 243 .repositoryDropTarget( 244 244 index: index, 245 245 repositoryOrderIDs: repositoryOrderIDs, 246 + isEnabled: isDragActive, 246 247 targetedDestination: $targetedRepositoryDropDestination, 247 - onDrop: { offsets, destination in 248 - store.send(.worktreeOrdering(.repositoriesMoved(offsets, destination))) 249 - }, 250 - onDragEnded: endSidebarDrag 248 + actions: SidebarDropTargetActions( 249 + onDrop: { offsets, destination in 250 + store.send(.worktreeOrdering(.repositoriesMoved(offsets, destination))) 251 + }, 252 + onDragEnded: endSidebarDrag 253 + ) 251 254 ) 252 255 } 253 256
+1
supacode/Features/Repositories/Views/WorktreeRow.swift
··· 73 73 .font(.body) 74 74 .foregroundStyle(nameColor) 75 75 .lineLimit(1) 76 + .truncationMode(.tail) 76 77 Spacer(minLength: 4) 77 78 if isHovered, pinAction != nil { 78 79 Button {
+79 -57
supacode/Features/Repositories/Views/WorktreeRowsView.swift
··· 89 89 shortcutIndexByID: [Worktree.ID: Int] 90 90 ) -> some View { 91 91 let rowIDs = rows.map(\.id) 92 + let isWorktreeDragActive = !draggingWorktreeIDs.isEmpty 92 93 ForEach(Array(rows.enumerated()), id: \.element.id) { index, row in 93 94 rowView( 94 95 row, ··· 99 100 .worktreeDropTarget( 100 101 index: index, 101 102 rowIDs: rowIDs, 103 + isEnabled: isWorktreeDragActive, 102 104 targetedDestination: targetedDestination, 103 - onDrop: { offsets, destination in 104 - moveWorktrees(section: section, offsets: offsets, destination: destination) 105 - }, 106 - onDragEnded: endWorktreeDrag 105 + actions: SidebarDropTargetActions( 106 + onDrop: { offsets, destination in 107 + moveWorktrees(section: section, offsets: offsets, destination: destination) 108 + }, 109 + onDragEnded: endWorktreeDrag 110 + ) 107 111 ) 108 112 } 109 113 } ··· 116 120 shortcutHint: String? 117 121 ) -> some View { 118 122 let isWorktreeDragActive = !draggingWorktreeIDs.isEmpty 119 - let showsNotificationIndicator = terminalManager.hasUnseenNotifications(for: row.id) 120 - let displayName = 121 - if row.isDeleting { 122 - "\(row.name) (deleting...)" 123 - } else if row.isArchiving { 124 - "\(row.name) (archiving...)" 125 - } else { 126 - row.name 127 - } 128 - let canShowRowActions = row.isRemovable && !isRepositoryRemoving && !isWorktreeDragActive 129 - let pinAction: (() -> Void)? = 130 - canShowRowActions && !row.isMainWorktree 131 - ? { togglePin(for: row.id, isPinned: row.isPinned) } 132 - : nil 133 - let archiveAction: (() -> Void)? = 134 - canShowRowActions && !row.isMainWorktree 135 - ? { archiveWorktree(row.id) } 136 - : nil 137 - let notifications = terminalManager.stateIfExists(for: row.id)?.notifications ?? [] 138 - let onFocusNotification: (WorktreeTerminalNotification) -> Void = { notification in 139 - guard let terminalState = terminalManager.stateIfExists(for: row.id) else { 140 - return 141 - } 142 - _ = terminalState.focusSurface(id: notification.surfaceId) 143 - } 144 - let onDiffTap: (() -> Void)? = { 145 - guard let worktree = store.state.worktree(for: row.id) else { return } 146 - DiffWindowManager.shared.show( 147 - worktreeURL: worktree.workingDirectory, 148 - branchName: worktree.name, 149 - resolvedKeybindings: resolvedKeybindings 150 - ) 151 - } 152 - let onStopRunScript: (() -> Void)? = 153 - terminalManager.isRunScriptRunning(for: row.id) 154 - ? { _ = terminalManager.stateIfExists(for: row.id)?.stopRunScript() } 155 - : nil 156 - let config = WorktreeRowViewConfig( 157 - displayName: displayName, 158 - worktreeName: worktreeName(for: row), 159 - isHovered: !isWorktreeDragActive && hoveredWorktreeID == row.id, 160 - showsNotificationIndicator: !isWorktreeDragActive && showsNotificationIndicator, 161 - notifications: isWorktreeDragActive ? [] : notifications, 162 - onFocusNotification: onFocusNotification, 163 - shortcutHint: shortcutHint, 164 - pinAction: pinAction, 165 - archiveAction: archiveAction, 166 - onDiffTap: onDiffTap, 167 - onStopRunScript: onStopRunScript, 123 + let config = rowConfig( 124 + for: row, 125 + isRepositoryRemoving: isRepositoryRemoving, 126 + isWorktreeDragActive: isWorktreeDragActive, 168 127 moveDisabled: moveDisabled, 128 + shortcutHint: shortcutHint 169 129 ) 170 130 let baseRow = worktreeRowView(row, config: config) 131 + .disabled(isRepositoryRemoving) 132 + .contentShape(.dragPreview, .rect) 133 + .contentShape(.interaction, .rect) 134 + .contentShape(Rectangle()) 171 135 Group { 172 136 if row.isRemovable, let worktree = store.state.worktree(for: row.id), !isRepositoryRemoving { 173 137 baseRow.contextMenu { 174 138 rowContextMenu(worktree: worktree, row: row) 175 139 } 176 140 } else { 177 - baseRow.disabled(isRepositoryRemoving) 141 + baseRow 178 142 } 179 143 } 180 - .contentShape(.dragPreview, .rect) 181 - .contentShape(.interaction, .rect) 182 144 .onTapGesture { 183 145 selectWorktreeRow(row.id) 184 146 } ··· 216 178 } 217 179 } 218 180 181 + private func rowConfig( 182 + for row: WorktreeRowModel, 183 + isRepositoryRemoving: Bool, 184 + isWorktreeDragActive: Bool, 185 + moveDisabled: Bool, 186 + shortcutHint: String? 187 + ) -> WorktreeRowViewConfig { 188 + let displayName = 189 + if row.isDeleting { 190 + "\(row.name) (deleting...)" 191 + } else if row.isArchiving { 192 + "\(row.name) (archiving...)" 193 + } else { 194 + row.name 195 + } 196 + let showsNotificationIndicator = terminalManager.hasUnseenNotifications(for: row.id) 197 + let notifications = terminalManager.stateIfExists(for: row.id)?.notifications ?? [] 198 + let canShowRowActions = row.isRemovable && !isRepositoryRemoving && !isWorktreeDragActive 199 + return WorktreeRowViewConfig( 200 + displayName: displayName, 201 + worktreeName: worktreeName(for: row), 202 + isHovered: !isWorktreeDragActive && hoveredWorktreeID == row.id, 203 + showsNotificationIndicator: !isWorktreeDragActive && showsNotificationIndicator, 204 + notifications: isWorktreeDragActive ? [] : notifications, 205 + onFocusNotification: focusNotificationHandler(for: row.id), 206 + shortcutHint: shortcutHint, 207 + pinAction: canShowRowActions && !row.isMainWorktree ? { togglePin(for: row.id, isPinned: row.isPinned) } : nil, 208 + archiveAction: canShowRowActions && !row.isMainWorktree ? { archiveWorktree(row.id) } : nil, 209 + onDiffTap: diffTapHandler(for: row.id), 210 + onStopRunScript: stopRunScriptHandler(for: row.id), 211 + moveDisabled: moveDisabled, 212 + ) 213 + } 214 + 215 + private func focusNotificationHandler(for worktreeID: Worktree.ID) -> (WorktreeTerminalNotification) -> Void { 216 + { notification in 217 + guard let terminalState = terminalManager.stateIfExists(for: worktreeID) else { 218 + return 219 + } 220 + _ = terminalState.focusSurface(id: notification.surfaceId) 221 + } 222 + } 223 + 224 + private func diffTapHandler(for worktreeID: Worktree.ID) -> (() -> Void)? { 225 + { 226 + guard let worktree = store.state.worktree(for: worktreeID) else { return } 227 + DiffWindowManager.shared.show( 228 + worktreeURL: worktree.workingDirectory, 229 + branchName: worktree.name, 230 + resolvedKeybindings: resolvedKeybindings 231 + ) 232 + } 233 + } 234 + 235 + private func stopRunScriptHandler(for worktreeID: Worktree.ID) -> (() -> Void)? { 236 + terminalManager.isRunScriptRunning(for: worktreeID) 237 + ? { _ = terminalManager.stateIfExists(for: worktreeID)?.stopRunScript() } 238 + : nil 239 + } 240 + 219 241 private func handleWorktreeDragSession( 220 242 draggedIDs: Set<Worktree.ID>, 221 243 didEnd: Bool ··· 224 246 endWorktreeDrag() 225 247 return 226 248 } 227 - if draggedIDs != draggingWorktreeIDs { 249 + if !draggedIDs.isEmpty, draggedIDs != draggingWorktreeIDs { 228 250 draggingWorktreeIDs = draggedIDs 229 251 } 230 252 }
+54
supacodeTests/SidebarDragSupportTests.swift
··· 1 + import Testing 2 + 3 + @testable import supacode 4 + 5 + struct SidebarDragSupportTests { 6 + @Test func dropIndicatorOnlyDrawsOneEdgeForInteriorDestinations() { 7 + #expect( 8 + SidebarDropIndicatorEdge.edge( 9 + targetedDestination: 1, 10 + rowIndex: 0, 11 + rowCount: 3 12 + ) 13 + == .none 14 + ) 15 + #expect( 16 + SidebarDropIndicatorEdge.edge( 17 + targetedDestination: 1, 18 + rowIndex: 1, 19 + rowCount: 3 20 + ) 21 + == .top 22 + ) 23 + } 24 + 25 + @Test func dropIndicatorDrawsTopAndFinalBottomBoundaries() { 26 + #expect( 27 + SidebarDropIndicatorEdge.edge( 28 + targetedDestination: 0, 29 + rowIndex: 0, 30 + rowCount: 3 31 + ) 32 + == .top 33 + ) 34 + #expect( 35 + SidebarDropIndicatorEdge.edge( 36 + targetedDestination: 3, 37 + rowIndex: 2, 38 + rowCount: 3 39 + ) 40 + == .bottom 41 + ) 42 + } 43 + 44 + @Test func dropIndicatorHidesWithoutTarget() { 45 + #expect( 46 + SidebarDropIndicatorEdge.edge( 47 + targetedDestination: nil, 48 + rowIndex: 1, 49 + rowCount: 3 50 + ) 51 + == .none 52 + ) 53 + } 54 + }