native macOS codings agent orchestrator
6
fork

Configure Feed

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

Use sidebar container rows for repository drag

onevcat 304a2f8c 2fbf8535

+468 -153
+6
supacode/Features/Repositories/Models/SidebarPresentation.swift
··· 59 59 case archivedWorktrees 60 60 } 61 61 62 + enum SidebarScrollID: Equatable, Hashable { 63 + case repository(Repository.ID) 64 + case worktree(Worktree.ID) 65 + case archivedWorktrees 66 + } 67 + 62 68 struct SidebarListHeaderModel: Equatable, Identifiable { 63 69 let id = SidebarPresentationItemID.listHeader 64 70 var repositoryCount: Int
+13 -2
supacode/Features/Repositories/Views/RepositorySectionView.swift
··· 12 12 @Binding var expandedRepoIDs: Set<Repository.ID> 13 13 @Bindable var store: StoreOf<RepositoriesFeature> 14 14 let terminalManager: WorktreeTerminalManager 15 + let onRepositorySelected: () -> Void 15 16 @Environment(\.colorScheme) private var colorScheme 16 17 @Environment(\.resolvedKeybindings) private var resolvedKeybindings 17 18 @State private var isHovering = false ··· 21 22 let state = store.state 22 23 let isExpanded = expandedRepoIDs.contains(repository.id) 23 24 let isRemovingRepository = state.isRemovingRepository(repository) 25 + let isSelected = state.selection == .repository(repository.id) 24 26 let openRepoSettings = { 25 27 _ = store.send(.repositoryManagement(.openRepositorySettings(repository.id))) 26 28 } ··· 184 186 .padding(.bottom, hasTopSpacing && !repository.capabilities.supportsWorktrees ? 4 : 0) 185 187 .contentShape(.interaction, .rect) 186 188 .background { 187 - if Self.debugHeaderLayers { 189 + if isSelected { 190 + RoundedRectangle(cornerRadius: 5) 191 + .fill(Color.accentColor.opacity(0.18)) 192 + .padding(.horizontal, 6) 193 + } else if Self.debugHeaderLayers { 188 194 Rectangle() 189 195 .fill(.red.opacity(0.12)) 190 196 .overlay { ··· 194 200 } 195 201 } 196 202 .onHover { isHovering = $0 } 203 + .onTapGesture { 204 + onRepositorySelected() 205 + } 206 + .accessibilityAddTraits(.isButton) 197 207 .contentShape(.rect) 198 208 .contextMenu { 199 209 Button("Repo Settings") { ··· 211 221 .environment(\.colorScheme, colorScheme) 212 222 .preferredColorScheme(colorScheme) 213 223 214 - Group { 224 + VStack(spacing: 0) { 215 225 header 216 226 .tag(SidebarSelection.repository(repository.id)) 217 227 if isExpanded { ··· 225 235 ) 226 236 } 227 237 } 238 + .id(SidebarScrollID.repository(repository.id)) 228 239 } 229 240 230 241 private var headerCellHeight: CGFloat {
+172
supacode/Features/Repositories/Views/SidebarDragSupport.swift
··· 1 + import SwiftUI 2 + import UniformTypeIdentifiers 3 + 4 + extension UTType { 5 + static let prowlSidebarRepositoryID = UTType(exportedAs: "com.onevcat.prowl.sidebar.repository-id") 6 + static let prowlSidebarWorktreeID = UTType(exportedAs: "com.onevcat.prowl.sidebar.worktree-id") 7 + } 8 + 9 + enum SidebarDragProvider { 10 + static func repository(id: Repository.ID) -> NSItemProvider { 11 + itemProvider(id: id, type: .prowlSidebarRepositoryID) 12 + } 13 + 14 + static func worktree(id: Worktree.ID) -> NSItemProvider { 15 + itemProvider(id: id, type: .prowlSidebarWorktreeID) 16 + } 17 + 18 + private static func itemProvider(id: String, type: UTType) -> NSItemProvider { 19 + let provider = NSItemProvider() 20 + provider.registerDataRepresentation(forTypeIdentifier: type.identifier, visibility: .all) { completion in 21 + completion(Data(id.utf8), nil) 22 + return nil 23 + } 24 + return provider 25 + } 26 + } 27 + 28 + struct SidebarRepositoryDropDelegate: DropDelegate { 29 + let destination: Int 30 + let repositoryOrderIDs: [Repository.ID] 31 + @Binding var targetedDestination: Int? 32 + let onDrop: (IndexSet, Int) -> Void 33 + let onDragEnded: () -> Void 34 + 35 + func dropEntered(info: DropInfo) { 36 + targetedDestination = destination 37 + } 38 + 39 + func dropExited(info: DropInfo) { 40 + if targetedDestination == destination { 41 + targetedDestination = nil 42 + } 43 + } 44 + 45 + func dropUpdated(info: DropInfo) -> DropProposal? { 46 + DropProposal(operation: .move) 47 + } 48 + 49 + func performDrop(info: DropInfo) -> Bool { 50 + targetedDestination = nil 51 + guard let provider = info.itemProviders(for: [.prowlSidebarRepositoryID]).first else { 52 + onDragEnded() 53 + return false 54 + } 55 + provider.loadDataRepresentation(forTypeIdentifier: UTType.prowlSidebarRepositoryID.identifier) { data, _ in 56 + guard let data, 57 + let repositoryID = String(data: data, encoding: .utf8), 58 + let source = repositoryOrderIDs.firstIndex(of: repositoryID), 59 + source != destination, 60 + source + 1 != destination 61 + else { 62 + Task { @MainActor in onDragEnded() } 63 + return 64 + } 65 + Task { @MainActor in 66 + onDrop(IndexSet(integer: source), destination) 67 + onDragEnded() 68 + } 69 + } 70 + return true 71 + } 72 + } 73 + 74 + struct SidebarWorktreeDropDelegate: DropDelegate { 75 + let destination: Int 76 + let sectionIDs: [Worktree.ID] 77 + @Binding var targetedDestination: Int? 78 + let onDrop: (IndexSet, Int) -> Void 79 + let onDragEnded: () -> Void 80 + 81 + func dropEntered(info: DropInfo) { 82 + targetedDestination = destination 83 + } 84 + 85 + func dropExited(info: DropInfo) { 86 + if targetedDestination == destination { 87 + targetedDestination = nil 88 + } 89 + } 90 + 91 + func dropUpdated(info: DropInfo) -> DropProposal? { 92 + DropProposal(operation: .move) 93 + } 94 + 95 + func performDrop(info: DropInfo) -> Bool { 96 + targetedDestination = nil 97 + guard let provider = info.itemProviders(for: [.prowlSidebarWorktreeID]).first else { 98 + onDragEnded() 99 + return false 100 + } 101 + provider.loadDataRepresentation(forTypeIdentifier: UTType.prowlSidebarWorktreeID.identifier) { data, _ in 102 + guard let data, 103 + let worktreeID = String(data: data, encoding: .utf8), 104 + let source = sectionIDs.firstIndex(of: worktreeID), 105 + source != destination, 106 + source + 1 != destination 107 + else { 108 + Task { @MainActor in onDragEnded() } 109 + return 110 + } 111 + Task { @MainActor in 112 + onDrop(IndexSet(integer: source), destination) 113 + onDragEnded() 114 + } 115 + } 116 + return true 117 + } 118 + } 119 + 120 + struct SidebarDropIndicator: View { 121 + let isVisible: Bool 122 + var horizontalPadding: CGFloat = 12 123 + 124 + var body: some View { 125 + ZStack { 126 + if isVisible { 127 + Capsule() 128 + .fill(Color.accentColor) 129 + .frame(height: 2) 130 + .padding(.horizontal, horizontalPadding) 131 + .transition(.opacity) 132 + } 133 + } 134 + .frame(maxWidth: .infinity) 135 + .frame(height: 6) 136 + .accessibilityHidden(true) 137 + } 138 + } 139 + 140 + extension View { 141 + @ViewBuilder 142 + func draggableRepository( 143 + id: Repository.ID, 144 + isEnabled: Bool, 145 + beginDrag: @escaping () -> Void 146 + ) -> some View { 147 + if isEnabled { 148 + self.onDrag { 149 + beginDrag() 150 + return SidebarDragProvider.repository(id: id) 151 + } 152 + } else { 153 + self 154 + } 155 + } 156 + 157 + @ViewBuilder 158 + func draggableWorktree( 159 + id: Worktree.ID, 160 + isEnabled: Bool, 161 + beginDrag: @escaping () -> Void 162 + ) -> some View { 163 + if isEnabled { 164 + self.onDrag { 165 + beginDrag() 166 + return SidebarDragProvider.worktree(id: id) 167 + } 168 + } else { 169 + self 170 + } 171 + } 172 + }
+102 -117
supacode/Features/Repositories/Views/SidebarListView.swift
··· 35 35 let terminalManager: WorktreeTerminalManager 36 36 @FocusState private var isSidebarFocused: Bool 37 37 @State private var isDragActive = false 38 + @State private var targetedRepositoryDropDestination: Int? 38 39 39 40 var body: some View { 40 41 let state = store.state ··· 52 53 } 53 54 return false 54 55 } 55 - let selectedWorktreeIDs = Set(sidebarSelections.compactMap(\.worktreeID)) 56 - let selection = Binding<Set<SidebarSelection>>( 57 - get: { 58 - var nextSelections = sidebarSelections 59 - if state.isShowingCanvas { 60 - nextSelections = [.canvas] 61 - } else if state.isShowingArchivedWorktrees { 62 - nextSelections = [.archivedWorktrees] 63 - } else { 64 - nextSelections.remove(.archivedWorktrees) 65 - nextSelections.remove(.canvas) 66 - if let selectedRepository = state.selectedRepository, selectedRepository.kind == .plain { 67 - nextSelections = [.repository(selectedRepository.id)] 68 - } else if let selectedWorktreeID = state.selectedWorktreeID { 69 - nextSelections.insert(.worktree(selectedWorktreeID)) 70 - } 71 - } 72 - return nextSelections 73 - }, 74 - set: { newValue in 75 - let nextSelections = newValue 76 - let repositorySelections: [Repository.ID] = nextSelections.compactMap { selection in 77 - guard case .repository(let repositoryID) = selection else { return nil } 78 - return repositoryID 79 - } 56 + let selectedWorktreeIDs = Self.selectedWorktreeIDs(in: state) 57 + let pendingSidebarReveal = state.pendingSidebarReveal 80 58 81 - if nextSelections.contains(.canvas) { 82 - sidebarSelections = [.canvas] 83 - store.send(.selectCanvas) 84 - return 85 - } 86 - 87 - if nextSelections.contains(.archivedWorktrees) { 88 - sidebarSelections = [.archivedWorktrees] 89 - store.send(.selectArchivedWorktrees) 90 - return 91 - } 92 - 93 - if let repositoryID = repositorySelections.first { 94 - guard let repository = state.repositories[id: repositoryID] else { 95 - return 96 - } 97 - if repository.capabilities.supportsWorktrees { 98 - withAnimation(.easeOut(duration: 0.2)) { 99 - if expandedRepoIDs.contains(repositoryID) { 100 - expandedRepoIDs.remove(repositoryID) 101 - } else { 102 - expandedRepoIDs.insert(repositoryID) 103 - } 104 - } 105 - sidebarSelections = [] 106 - } else { 107 - sidebarSelections = [.repository(repositoryID)] 108 - store.send(.selectRepository(repositoryID)) 109 - focusTerminalAfterSidebarSelection(worktreeID: store.state.selectedTerminalWorktree?.id) 59 + ScrollViewReader { scrollProxy in 60 + ScrollView { 61 + LazyVStack(spacing: 0) { 62 + if showsRepositoryListHeader { 63 + repositoryListHeader( 64 + action: repositoryListHeaderAction, 65 + expandableRepositoryIDs: expandableRepositoryIDs 66 + ) 110 67 } 111 - return 112 - } 113 68 114 - let worktreeIDs = Set(nextSelections.compactMap(\.worktreeID)) 115 - guard !worktreeIDs.isEmpty else { 116 - sidebarSelections = [] 117 - store.send(.selectWorktree(nil)) 118 - return 119 - } 120 - let shouldFocusTerminal = worktreeIDs.count == 1 121 - sidebarSelections = Set(worktreeIDs.map(SidebarSelection.worktree)) 122 - if let selectedWorktreeID = state.selectedWorktreeID, 123 - worktreeIDs.contains(selectedWorktreeID) 124 - { 125 - if shouldFocusTerminal { 126 - focusTerminalAfterSidebarSelection(worktreeID: selectedWorktreeID) 69 + ForEach(Array(repositoryItems.enumerated()), id: \.element.id) { index, item in 70 + repositoryDropZone( 71 + destination: index, 72 + repositoryOrderIDs: presentation.repositoryOrderIDs 73 + ) 74 + repositoryItemView( 75 + item, 76 + index: index, 77 + hotkeyRows: hotkeyRows, 78 + selectedWorktreeIDs: selectedWorktreeIDs 79 + ) 127 80 } 128 - return 129 - } 130 - let nextPrimarySelection = 131 - hotkeyRows.map(\.id).first(where: worktreeIDs.contains) 132 - ?? worktreeIDs.first 133 - store.send(.selectWorktree(nextPrimarySelection, focusTerminal: shouldFocusTerminal)) 134 - if shouldFocusTerminal { 135 - focusTerminalAfterSidebarSelection(worktreeID: nextPrimarySelection) 136 - } 137 - } 138 - ) 139 - let pendingSidebarReveal = state.pendingSidebarReveal 140 - 141 - ScrollViewReader { scrollProxy in 142 - List(selection: selection) { 143 - if showsRepositoryListHeader { 144 - repositoryListHeader( 145 - action: repositoryListHeaderAction, 146 - expandableRepositoryIDs: expandableRepositoryIDs 81 + repositoryDropZone( 82 + destination: repositoryItems.count, 83 + repositoryOrderIDs: presentation.repositoryOrderIDs 147 84 ) 148 - .listRowInsets(EdgeInsets()) 149 85 } 150 - 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))) 162 - } 86 + .padding(.vertical, 2) 163 87 } 164 - .listStyle(.sidebar) 165 88 .scrollIndicators(.never) 166 89 .frame(minWidth: 220) 90 + .background(.bar) 167 91 .onDragSessionUpdated { session in 168 92 if case .ended = session.phase { 169 - if isDragActive { 170 - isDragActive = false 171 - store.send(.worktreeOrdering(.setSidebarDragActive(false))) 172 - } 93 + endSidebarDrag() 173 94 return 174 95 } 175 96 if case .dataTransferCompleted = session.phase { 176 - if isDragActive { 177 - isDragActive = false 178 - store.send(.worktreeOrdering(.setSidebarDragActive(false))) 179 - } 97 + endSidebarDrag() 180 98 return 181 99 } 182 - if !isDragActive { 183 - isDragActive = true 184 - store.send(.worktreeOrdering(.setSidebarDragActive(true))) 185 - } 100 + beginSidebarDrag() 186 101 } 187 102 .safeAreaInset(edge: .top) { 188 103 HStack(spacing: 4) { ··· 285 200 selectedWorktreeIDs: selectedWorktreeIDs, 286 201 expandedRepoIDs: $expandedRepoIDs, 287 202 store: store, 288 - terminalManager: terminalManager 203 + terminalManager: terminalManager, 204 + onRepositorySelected: { 205 + selectRepository(repository) 206 + } 207 + ) 208 + .draggableRepository( 209 + id: model.repositoryID, 210 + isEnabled: !model.isRemoving, 211 + beginDrag: beginSidebarDrag 289 212 ) 290 213 } 291 214 ··· 311 234 .accessibilityHidden(true) 312 235 } 313 236 } 237 + .draggableRepository( 238 + id: model.id, 239 + isEnabled: model.isReorderable, 240 + beginDrag: beginSidebarDrag 241 + ) 314 242 315 243 case .listHeader, .archivedWorktrees: 316 244 EmptyView() 317 245 } 318 246 } 319 247 248 + private func repositoryDropZone( 249 + destination: Int, 250 + repositoryOrderIDs: [Repository.ID] 251 + ) -> some View { 252 + SidebarDropIndicator(isVisible: targetedRepositoryDropDestination == destination) 253 + .onDrop( 254 + of: [.prowlSidebarRepositoryID], 255 + delegate: SidebarRepositoryDropDelegate( 256 + destination: destination, 257 + repositoryOrderIDs: repositoryOrderIDs, 258 + targetedDestination: $targetedRepositoryDropDestination, 259 + onDrop: { offsets, destination in 260 + store.send(.worktreeOrdering(.repositoriesMoved(offsets, destination))) 261 + }, 262 + onDragEnded: endSidebarDrag 263 + ) 264 + ) 265 + } 266 + 267 + private func beginSidebarDrag() { 268 + guard !isDragActive else { return } 269 + isDragActive = true 270 + store.send(.worktreeOrdering(.setSidebarDragActive(true))) 271 + } 272 + 273 + private func endSidebarDrag() { 274 + targetedRepositoryDropDestination = nil 275 + guard isDragActive else { return } 276 + isDragActive = false 277 + store.send(.worktreeOrdering(.setSidebarDragActive(false))) 278 + } 279 + 280 + private func selectRepository(_ repository: Repository) { 281 + if repository.capabilities.supportsWorktrees { 282 + withAnimation(.easeOut(duration: 0.2)) { 283 + if expandedRepoIDs.contains(repository.id) { 284 + expandedRepoIDs.remove(repository.id) 285 + } else { 286 + expandedRepoIDs.insert(repository.id) 287 + } 288 + } 289 + sidebarSelections = [] 290 + } else { 291 + sidebarSelections = [.repository(repository.id)] 292 + store.send(.selectRepository(repository.id)) 293 + focusTerminalAfterSidebarSelection(worktreeID: store.state.selectedTerminalWorktree?.id) 294 + } 295 + } 296 + 320 297 @MainActor 321 298 private func revealPendingSidebarWorktree( 322 299 _ pendingSidebarReveal: PendingSidebarReveal?, ··· 328 305 await Task.yield() 329 306 isSidebarFocused = true 330 307 withAnimation(.easeOut(duration: 0.2)) { 331 - scrollProxy.scrollTo(pendingSidebarReveal.worktreeID, anchor: .center) 308 + scrollProxy.scrollTo(SidebarScrollID.worktree(pendingSidebarReveal.worktreeID), anchor: .center) 332 309 } 333 310 store.send(.consumePendingSidebarReveal(pendingSidebarReveal.id)) 334 311 } ··· 354 331 355 332 static func showsRepositoryListHeader(repositoryCount: Int) -> Bool { 356 333 SidebarPresentation.showsListHeader(repositoryCount: repositoryCount) 334 + } 335 + 336 + static func selectedWorktreeIDs(in state: RepositoriesFeature.State) -> Set<Worktree.ID> { 337 + var selectedWorktreeIDs = state.sidebarSelectedWorktreeIDs 338 + if let selectedWorktreeID = state.selectedWorktreeID { 339 + selectedWorktreeIDs.insert(selectedWorktreeID) 340 + } 341 + return selectedWorktreeIDs 357 342 } 358 343 } 359 344
+153 -34
supacode/Features/Repositories/Views/WorktreeRowsView.swift
··· 14 14 @Environment(\.resolvedKeybindings) private var resolvedKeybindings 15 15 @State private var draggingWorktreeIDs: Set<Worktree.ID> = [] 16 16 @State private var hoveredWorktreeID: Worktree.ID? 17 + @State private var targetedPinnedDropDestination: Int? 18 + @State private var targetedUnpinnedDropDestination: Int? 17 19 18 20 var body: some View { 19 21 if isExpanded { ··· 35 37 return rowsGroup( 36 38 sections: sections, 37 39 isRepositoryRemoving: isRepositoryRemoving, 38 - showShortcutHints: showShortcutHints, 39 40 shortcutIndexByID: shortcutIndexByID 40 41 ) 41 42 .animation(isSidebarDragActive ? nil : .easeOut(duration: 0.2), value: rowIDs) ··· 45 46 private func rowsGroup( 46 47 sections: WorktreeRowSections, 47 48 isRepositoryRemoving: Bool, 48 - showShortcutHints: Bool, 49 49 shortcutIndexByID: [Worktree.ID: Int] 50 50 ) -> some View { 51 51 if let row = sections.main { ··· 53 53 row, 54 54 isRepositoryRemoving: isRepositoryRemoving, 55 55 moveDisabled: true, 56 - shortcutHint: showShortcutHints ? worktreeShortcutHint(for: shortcutIndexByID[row.id]) : nil 57 - ) 58 - } 59 - ForEach(sections.pinned) { row in 60 - rowView( 61 - row, 62 - isRepositoryRemoving: isRepositoryRemoving, 63 - moveDisabled: isRepositoryRemoving || row.isDeleting || row.isArchiving, 64 - shortcutHint: showShortcutHints ? worktreeShortcutHint(for: shortcutIndexByID[row.id]) : nil 56 + shortcutHint: worktreeShortcutHint(for: shortcutIndexByID[row.id]) 65 57 ) 66 58 } 67 - .onMove { offsets, destination in 68 - store.send(.worktreeOrdering(.pinnedWorktreesMoved(repositoryID: repository.id, offsets, destination))) 69 - } 59 + movableRowsGroup( 60 + rows: sections.pinned, 61 + section: .pinned, 62 + targetedDestination: $targetedPinnedDropDestination, 63 + isRepositoryRemoving: isRepositoryRemoving, 64 + shortcutIndexByID: shortcutIndexByID 65 + ) 70 66 ForEach(sections.pending) { row in 71 67 rowView( 72 68 row, 73 69 isRepositoryRemoving: isRepositoryRemoving, 74 70 moveDisabled: true, 75 - shortcutHint: showShortcutHints ? worktreeShortcutHint(for: shortcutIndexByID[row.id]) : nil 71 + shortcutHint: worktreeShortcutHint(for: shortcutIndexByID[row.id]) 76 72 ) 77 73 } 78 - ForEach(sections.unpinned) { row in 74 + movableRowsGroup( 75 + rows: sections.unpinned, 76 + section: .unpinned, 77 + targetedDestination: $targetedUnpinnedDropDestination, 78 + isRepositoryRemoving: isRepositoryRemoving, 79 + shortcutIndexByID: shortcutIndexByID 80 + ) 81 + } 82 + 83 + @ViewBuilder 84 + private func movableRowsGroup( 85 + rows: [WorktreeRowModel], 86 + section: SidebarWorktreeSection, 87 + targetedDestination: Binding<Int?>, 88 + isRepositoryRemoving: Bool, 89 + shortcutIndexByID: [Worktree.ID: Int] 90 + ) -> some View { 91 + let rowIDs = rows.map(\.id) 92 + ForEach(Array(rows.enumerated()), id: \.element.id) { index, row in 93 + worktreeDropZone( 94 + destination: index, 95 + rowIDs: rowIDs, 96 + targetedDestination: targetedDestination, 97 + section: section 98 + ) 79 99 rowView( 80 100 row, 81 101 isRepositoryRemoving: isRepositoryRemoving, 82 102 moveDisabled: isRepositoryRemoving || row.isDeleting || row.isArchiving, 83 - shortcutHint: showShortcutHints ? worktreeShortcutHint(for: shortcutIndexByID[row.id]) : nil 103 + shortcutHint: worktreeShortcutHint(for: shortcutIndexByID[row.id]) 84 104 ) 85 105 } 86 - .onMove { offsets, destination in 87 - store.send(.worktreeOrdering(.unpinnedWorktreesMoved(repositoryID: repository.id, offsets, destination))) 88 - } 106 + worktreeDropZone( 107 + destination: rows.count, 108 + rowIDs: rowIDs, 109 + targetedDestination: targetedDestination, 110 + section: section 111 + ) 89 112 } 90 113 91 114 @ViewBuilder ··· 159 182 } 160 183 .contentShape(.dragPreview, .rect) 161 184 .contentShape(.interaction, .rect) 185 + .onTapGesture { 186 + selectWorktreeRow(row.id) 187 + } 188 + .accessibilityAddTraits(.isButton) 189 + .draggableWorktree( 190 + id: row.id, 191 + isEnabled: !moveDisabled, 192 + beginDrag: { 193 + draggingWorktreeIDs = [row.id] 194 + store.send(.worktreeOrdering(.setSidebarDragActive(true))) 195 + } 196 + ) 162 197 .environment(\.colorScheme, colorScheme) 163 198 .preferredColorScheme(colorScheme) 164 199 .onHover { hovering in ··· 169 204 } 170 205 } 171 206 .onDragSessionUpdated { session in 172 - let draggedIDs = Set(session.draggedItemIDs(for: Worktree.ID.self)) 173 - if case .ended = session.phase { 174 - if !draggingWorktreeIDs.isEmpty { 175 - draggingWorktreeIDs = [] 207 + let didEnd = 208 + if case .ended = session.phase { 209 + true 210 + } else if case .dataTransferCompleted = session.phase { 211 + true 212 + } else { 213 + false 176 214 } 215 + handleWorktreeDragSession( 216 + draggedIDs: Set(session.draggedItemIDs(for: Worktree.ID.self)), 217 + didEnd: didEnd 218 + ) 219 + } 220 + } 221 + 222 + private func handleWorktreeDragSession( 223 + draggedIDs: Set<Worktree.ID>, 224 + didEnd: Bool 225 + ) { 226 + if didEnd { 227 + draggingWorktreeIDs = [] 228 + return 229 + } 230 + if draggedIDs != draggingWorktreeIDs { 231 + draggingWorktreeIDs = draggedIDs 232 + } 233 + } 234 + 235 + private func worktreeDropZone( 236 + destination: Int, 237 + rowIDs: [Worktree.ID], 238 + targetedDestination: Binding<Int?>, 239 + section: SidebarWorktreeSection 240 + ) -> some View { 241 + SidebarDropIndicator(isVisible: targetedDestination.wrappedValue == destination, horizontalPadding: 28) 242 + .onDrop( 243 + of: [.prowlSidebarWorktreeID], 244 + delegate: SidebarWorktreeDropDelegate( 245 + destination: destination, 246 + sectionIDs: rowIDs, 247 + targetedDestination: targetedDestination, 248 + onDrop: { offsets, destination in 249 + switch section { 250 + case .pinned: 251 + store.send(.worktreeOrdering(.pinnedWorktreesMoved(repositoryID: repository.id, offsets, destination))) 252 + case .unpinned: 253 + store.send(.worktreeOrdering(.unpinnedWorktreesMoved(repositoryID: repository.id, offsets, destination))) 254 + } 255 + }, 256 + onDragEnded: endWorktreeDrag 257 + ) 258 + ) 259 + } 260 + 261 + private func selectWorktreeRow(_ worktreeID: Worktree.ID) { 262 + if commandKeyObserver.isPressed { 263 + var nextSelection = selectedWorktreeIDs 264 + if nextSelection.contains(worktreeID) { 265 + nextSelection.remove(worktreeID) 266 + } else { 267 + nextSelection.insert(worktreeID) 268 + } 269 + guard !nextSelection.isEmpty else { 270 + store.send(.selectWorktree(nil)) 177 271 return 178 272 } 179 - if case .dataTransferCompleted = session.phase { 180 - if !draggingWorktreeIDs.isEmpty { 181 - draggingWorktreeIDs = [] 273 + let primarySelection = 274 + hotkeyRows.map(\.id).first(where: nextSelection.contains) 275 + ?? nextSelection.first 276 + store.send(.selectWorktree(primarySelection, focusTerminal: false)) 277 + store.send(.setSidebarSelectedWorktreeIDs(nextSelection)) 278 + return 279 + } 280 + 281 + store.send(.selectWorktree(worktreeID, focusTerminal: true)) 282 + focusTerminalAfterSelection(worktreeID: worktreeID) 283 + } 284 + 285 + private func focusTerminalAfterSelection(worktreeID: Worktree.ID) { 286 + Task { @MainActor [terminalManager] in 287 + for _ in 0..<4 { 288 + await Task.yield() 289 + if let terminalState = terminalManager.stateIfExists(for: worktreeID) { 290 + terminalState.focusSelectedTab() 291 + return 182 292 } 183 - return 184 - } 185 - if draggedIDs != draggingWorktreeIDs { 186 - draggingWorktreeIDs = draggedIDs 187 293 } 188 294 } 295 + } 296 + 297 + private func endWorktreeDrag() { 298 + draggingWorktreeIDs = [] 299 + targetedPinnedDropDestination = nil 300 + targetedUnpinnedDropDestination = nil 301 + store.send(.worktreeOrdering(.setSidebarDragActive(false))) 189 302 } 190 303 191 304 private struct WorktreeRowViewConfig { ··· 230 343 onStopRunScript: config.onStopRunScript, 231 344 ) 232 345 .tag(SidebarSelection.worktree(row.id)) 233 - .id(row.id) 346 + .id(SidebarScrollID.worktree(row.id)) 234 347 .typeSelectEquivalent("") 235 - .listRowInsets(EdgeInsets()) 236 - .listRowSeparator(.hidden) 348 + .padding(.horizontal, 8) 349 + .background { 350 + if isSelected { 351 + RoundedRectangle(cornerRadius: 5) 352 + .fill(Color.accentColor.opacity(0.18)) 353 + .padding(.horizontal, 6) 354 + } 355 + } 237 356 .transition(.opacity) 238 357 .moveDisabled(config.moveDisabled) 239 358 }
+22
supacodeTests/RepositorySectionViewTests.swift
··· 69 69 #expect(SidebarListView.showsRepositoryListHeader(repositoryCount: 11)) 70 70 } 71 71 72 + @Test func explicitSelectionIncludesPrimarySelectedWorktree() { 73 + let worktree = Worktree( 74 + id: "/tmp/repo/wt", 75 + name: "wt", 76 + detail: "detail", 77 + workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt"), 78 + repositoryRootURL: URL(fileURLWithPath: "/tmp/repo") 79 + ) 80 + let repository = Repository( 81 + id: "/tmp/repo", 82 + rootURL: URL(fileURLWithPath: "/tmp/repo"), 83 + name: "repo", 84 + kind: .git, 85 + worktrees: [worktree] 86 + ) 87 + var state = RepositoriesFeature.State() 88 + state.repositories = [repository] 89 + state.selection = .worktree(worktree.id) 90 + 91 + #expect(SidebarListView.selectedWorktreeIDs(in: state) == [worktree.id]) 92 + } 93 + 72 94 @Test func openTabCountForGitRepositorySumsAllWorktrees() { 73 95 let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 74 96 let repositoryRootURL = URL(fileURLWithPath: "/tmp/repo")