native macOS codings agent orchestrator
5
fork

Configure Feed

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

Merge pull request #7 from supabitapp/khoi/remove-repo-flow

Add repository removal flow

authored by

khoi and committed by
GitHub
3afd5ef9 06c8dfff

+166 -16
+80 -8
supacode/ContentView.swift
··· 112 112 dismissButton: .default(Text("OK")) 113 113 ) 114 114 } 115 + .alert(item: $repositoryStore.removeRepositoryError) { error in 116 + Alert( 117 + title: Text(error.title), 118 + message: Text(error.message), 119 + dismissButton: .default(Text("OK")) 120 + ) 121 + } 115 122 .alert(item: $repositoryStore.loadError) { error in 116 123 Alert( 117 124 title: Text(error.title), ··· 150 157 } 151 158 .navigationTitle(selectedWorktree?.name ?? loadingInfo?.name ?? "Supacode") 152 159 .toolbar { 160 + let isOpenDisabled = selectedWorktree == nil || loadingInfo != nil 153 161 ToolbarItemGroup(placement: .primaryAction) { 154 162 Menu { 155 163 ForEach(OpenWorktreeAction.allCases) { action in ··· 169 177 } 170 178 .modifier(OpenActionShortcutModifier(shortcut: action.shortcut)) 171 179 .help(action.helpText) 172 - .disabled(selectedWorktree == nil) 180 + .disabled(isOpenDisabled) 173 181 } 174 182 } label: { 175 183 Label("Open", systemImage: "folder") 176 184 } 177 185 .help("Open Finder (\(AppShortcuts.openFinder.display))") 178 - .disabled(selectedWorktree == nil) 186 + .disabled(isOpenDisabled) 179 187 } 180 188 } 181 189 .alert(item: $openActionError) { error in ··· 216 224 @Environment(RepositoryStore.self) private var repositoryStore 217 225 @State private var expandedRepoIDs: Set<Repository.ID> 218 226 @State private var pendingRemoval: PendingWorktreeRemoval? 227 + @State private var pendingRepositoryRemoval: PendingRepositoryRemoval? 219 228 220 229 init( 221 230 repositories: [Repository], ··· 244 253 selection: $selection, 245 254 expandedRepoIDs: $expandedRepoIDs, 246 255 createWorktree: createWorktree, 247 - onRequestRemoval: requestRemoval 256 + onRequestRemoval: requestRemoval, 257 + onRequestRepositoryRemoval: requestRepositoryRemoval 248 258 ) 249 259 .focusedSceneValue(\.removeWorktreeAction, removeWorktreeAction) 250 260 .alert(item: $pendingRemoval) { candidate in ··· 263 273 secondaryButton: .cancel() 264 274 ) 265 275 } 276 + .alert(item: $pendingRepositoryRemoval) { candidate in 277 + Alert( 278 + title: Text("Remove repository?"), 279 + message: Text( 280 + "This removes the repository from Supacode and deletes all of its worktrees " 281 + + "and their branches created by Supacode. " 282 + + "The main repository folder is not deleted." 283 + ), 284 + primaryButton: .destructive(Text("Remove repository")) { 285 + Task { 286 + await repositoryStore.removeRepository(candidate.repository) 287 + } 288 + }, 289 + secondaryButton: .cancel() 290 + ) 291 + } 266 292 } 267 293 268 294 private func requestRemoval(_ worktree: Worktree, in repository: Repository) { ··· 285 311 } 286 312 } 287 313 } 314 + 315 + private func requestRepositoryRemoval(_ repository: Repository) { 316 + if repositoryStore.isRemovingRepository(repository) { 317 + return 318 + } 319 + pendingRepositoryRemoval = PendingRepositoryRemoval(repository: repository) 320 + } 288 321 } 289 322 290 323 private struct SidebarListView: View { ··· 294 327 @Binding var expandedRepoIDs: Set<Repository.ID> 295 328 let createWorktree: (Repository) -> Void 296 329 let onRequestRemoval: (Worktree, Repository) -> Void 330 + let onRequestRepositoryRemoval: (Repository) -> Void 297 331 298 332 var body: some View { 299 333 List(selection: $selection) { ··· 302 336 repository: repository, 303 337 expandedRepoIDs: $expandedRepoIDs, 304 338 createWorktree: createWorktree, 305 - onRequestRemoval: onRequestRemoval 339 + onRequestRemoval: onRequestRemoval, 340 + onRequestRepositoryRemoval: onRequestRepositoryRemoval 306 341 ) 307 342 } 308 343 } ··· 325 360 @Binding var expandedRepoIDs: Set<Repository.ID> 326 361 let createWorktree: (Repository) -> Void 327 362 let onRequestRemoval: (Worktree, Repository) -> Void 363 + let onRequestRepositoryRemoval: (Repository) -> Void 364 + @Environment(RepositoryStore.self) private var repositoryStore 328 365 329 366 var body: some View { 330 367 let isExpanded = expandedRepoIDs.contains(repository.id) 368 + let isRemovingRepository = repositoryStore.isRemovingRepository(repository) 331 369 Section { 332 370 WorktreeRowsView( 333 371 repository: repository, ··· 349 387 profileURL: repository.githubOwner.flatMap { 350 388 Github.profilePictureURL(username: $0, size: 48) 351 389 }, 352 - isExpanded: isExpanded 390 + isExpanded: isExpanded, 391 + isRemoving: isRemovingRepository 353 392 ) 354 393 } 355 394 .buttonStyle(.plain) 395 + .disabled(isRemovingRepository) 396 + .contextMenu { 397 + Button("Remove Repository") { 398 + onRequestRepositoryRemoval(repository) 399 + } 400 + .help("Remove repository (no shortcut)") 401 + .disabled(isRemovingRepository) 402 + } 356 403 Spacer() 404 + if isRemovingRepository { 405 + ProgressView() 406 + .controlSize(.small) 407 + } 357 408 Button("New Worktree", systemImage: "plus") { 358 409 createWorktree(repository) 359 410 } ··· 362 413 .foregroundStyle(.primary) 363 414 .padding(.trailing, 6) 364 415 .help("New Worktree (\(AppShortcuts.newWorktree.display))") 416 + .disabled(isRemovingRepository) 365 417 } 366 418 .padding() 367 419 } ··· 377 429 var body: some View { 378 430 if isExpanded { 379 431 let rows = repositoryStore.worktreeRows(in: repository) 432 + let isRepositoryRemoving = repositoryStore.isRemovingRepository(repository) 380 433 ForEach(rows) { row in 381 - rowView(row) 434 + rowView(row, isRepositoryRemoving: isRepositoryRemoving) 382 435 } 383 436 } 384 437 } 385 438 386 439 @ViewBuilder 387 - private func rowView(_ row: WorktreeRowModel) -> some View { 440 + private func rowView(_ row: WorktreeRowModel, isRepositoryRemoving: Bool) -> some View { 388 441 let displayDetail = row.isDeleting ? "Removing..." : row.detail 389 - if row.isRemovable, let worktree = repositoryStore.worktree(for: row.id) { 442 + if row.isRemovable, let worktree = repositoryStore.worktree(for: row.id), 443 + !isRepositoryRemoving 444 + { 390 445 WorktreeRow( 391 446 name: row.name, 392 447 detail: displayDetail, ··· 419 474 isLoading: row.isPending || row.isDeleting 420 475 ) 421 476 .tag(row.id) 477 + .disabled(isRepositoryRemoving) 422 478 } 423 479 } 424 480 } ··· 435 491 } 436 492 } 437 493 494 + private struct PendingRepositoryRemoval: Identifiable, Hashable { 495 + let id: Repository.ID 496 + let repository: Repository 497 + 498 + init(repository: Repository) { 499 + self.id = repository.id 500 + self.repository = repository 501 + } 502 + } 503 + 438 504 private struct RepoHeaderRow: View { 439 505 let name: String 440 506 let initials: String 441 507 let profileURL: URL? 442 508 let isExpanded: Bool 509 + let isRemoving: Bool 443 510 444 511 var body: some View { 445 512 HStack { ··· 470 537 Text(name) 471 538 .font(.headline) 472 539 .foregroundStyle(.primary) 540 + if isRemoving { 541 + Text("Removing...") 542 + .font(.caption) 543 + .foregroundStyle(.secondary) 544 + } 473 545 } 474 546 } 475 547 }
+7
supacode/RemoveRepositoryError.swift
··· 1 + import Foundation 2 + 3 + struct RemoveRepositoryError: Identifiable, Hashable { 4 + let id: UUID 5 + let title: String 6 + let message: String 7 + }
+79 -8
supacode/RepositoryStore.swift
··· 16 16 var openError: OpenRepositoryError? 17 17 var createWorktreeError: CreateWorktreeError? 18 18 var removeWorktreeError: RemoveWorktreeError? 19 + var removeRepositoryError: RemoveRepositoryError? 19 20 var loadError: LoadRepositoryError? 20 21 var pendingWorktrees: [PendingWorktree] = [] 21 22 var deletingWorktreeIDs: Set<Worktree.ID> = [] 23 + var removingRepositoryIDs: Set<Repository.ID> = [] 22 24 private(set) var pinnedWorktreeIDs: [Worktree.ID] = [] 23 25 24 26 var canCreateWorktree: Bool { 25 27 if repositories.isEmpty { 26 28 return false 27 29 } 28 - if selectedWorktreeID != nil { 29 - return true 30 + if let repository = repositoryForWorktreeCreation() { 31 + return !isRemovingRepository(repository) 30 32 } 31 - return repositories.count == 1 33 + return false 32 34 } 33 35 34 36 init(userDefaults: UserDefaults = .standard, gitClient: GitClient = .init()) { ··· 110 112 ) 111 113 return 112 114 } 115 + if isRemovingRepository(repository) { 116 + createWorktreeError = CreateWorktreeError( 117 + id: UUID(), 118 + title: "Unable to create worktree", 119 + message: "This repository is being removed." 120 + ) 121 + return 122 + } 113 123 114 124 await createRandomWorktree(in: repository) 115 125 } 116 126 117 127 func createRandomWorktree(in repository: Repository) async { 118 128 createWorktreeError = nil 129 + if isRemovingRepository(repository) { 130 + createWorktreeError = CreateWorktreeError( 131 + id: UUID(), 132 + title: "Unable to create worktree", 133 + message: "This repository is being removed." 134 + ) 135 + return 136 + } 119 137 let previousSelection = selectedWorktreeID 120 138 let pendingID = "pending:\(UUID().uuidString)" 121 139 let pendingWorktree = PendingWorktree( ··· 188 206 func worktreeRows(in repository: Repository) -> [WorktreeRowModel] { 189 207 let ordered = orderedWorktrees(in: repository) 190 208 let pinnedIDs = Set(pinnedWorktreeIDs) 209 + let isRemovingRepository = removingRepositoryIDs.contains(repository.id) 191 210 let pinnedWorktrees = ordered.filter { pinnedIDs.contains($0.id) } 192 211 let unpinnedWorktrees = ordered.filter { !pinnedIDs.contains($0.id) } 193 212 let pendingEntries = pendingWorktrees.filter { $0.repositoryID == repository.id } 194 213 var rows: [WorktreeRowModel] = [] 195 214 for worktree in pinnedWorktrees { 196 - let isDeleting = deletingWorktreeIDs.contains(worktree.id) 215 + let isDeleting = isRemovingRepository || deletingWorktreeIDs.contains(worktree.id) 197 216 rows.append( 198 217 WorktreeRowModel( 199 218 id: worktree.id, ··· 216 235 detail: pending.detail, 217 236 isPinned: false, 218 237 isPending: true, 219 - isDeleting: false, 238 + isDeleting: isRemovingRepository, 220 239 isRemovable: false 221 240 ) 222 241 ) 223 242 } 224 243 for worktree in unpinnedWorktrees { 225 - let isDeleting = deletingWorktreeIDs.contains(worktree.id) 244 + let isDeleting = isRemovingRepository || deletingWorktreeIDs.contains(worktree.id) 226 245 rows.append( 227 246 WorktreeRowModel( 228 247 id: worktree.id, ··· 242 261 func selectedRow(for id: Worktree.ID?) -> WorktreeRowModel? { 243 262 guard let id else { return nil } 244 263 if let pending = pendingWorktree(for: id) { 264 + let isDeleting = removingRepositoryIDs.contains(pending.repositoryID) 245 265 return WorktreeRowModel( 246 266 id: pending.id, 247 267 repositoryID: pending.repositoryID, ··· 249 269 detail: pending.detail, 250 270 isPinned: false, 251 271 isPending: true, 252 - isDeleting: false, 272 + isDeleting: isDeleting, 253 273 isRemovable: false 254 274 ) 255 275 } 256 276 for repository in repositories { 257 277 if let worktree = repository.worktrees.first(where: { $0.id == id }) { 258 - let isDeleting = deletingWorktreeIDs.contains(worktree.id) 278 + let isDeleting = 279 + removingRepositoryIDs.contains(repository.id) 280 + || deletingWorktreeIDs.contains(worktree.id) 259 281 return WorktreeRowModel( 260 282 id: worktree.id, 261 283 repositoryID: repository.id, ··· 298 320 pinnedWorktreeIDs.contains(worktree.id) 299 321 } 300 322 323 + func isRemovingRepository(_ repository: Repository) -> Bool { 324 + removingRepositoryIDs.contains(repository.id) 325 + } 326 + 301 327 func pinWorktree(_ worktree: Worktree) { 302 328 pinnedWorktreeIDs.removeAll { $0 == worktree.id } 303 329 pinnedWorktreeIDs.insert(worktree.id, at: 0) ··· 345 371 id: UUID(), 346 372 title: "Unable to remove worktree", 347 373 message: error.localizedDescription 374 + ) 375 + } 376 + } 377 + 378 + func removeRepository(_ repository: Repository) async { 379 + removeRepositoryError = nil 380 + if removingRepositoryIDs.contains(repository.id) { 381 + return 382 + } 383 + removingRepositoryIDs.insert(repository.id) 384 + defer { removingRepositoryIDs.remove(repository.id) } 385 + let selectionWasRemoved = 386 + selectedWorktreeID.map { id in 387 + repository.worktrees.contains(where: { $0.id == id }) 388 + } ?? false 389 + var failures: [String] = [] 390 + for worktree in repository.worktrees { 391 + do { 392 + _ = try await gitClient.removeWorktree( 393 + named: worktree.name, 394 + in: repository.rootURL, 395 + force: true 396 + ) 397 + } catch { 398 + failures.append(error.localizedDescription) 399 + } 400 + } 401 + if failures.isEmpty { 402 + let rootPaths = uniqueRootPaths(loadRootPaths()) 403 + let normalized = repository.rootURL.standardizedFileURL.path(percentEncoded: false) 404 + let remaining = rootPaths.filter { $0 != normalized } 405 + persistRootPaths(remaining) 406 + } 407 + let roots = uniqueRootPaths(loadRootPaths()).map { URL(fileURLWithPath: $0) } 408 + let loaded = await loadRepositories(for: roots) 409 + applyRepositories(loaded, animated: true) 410 + if selectionWasRemoved { 411 + selectedWorktreeID = firstAvailableWorktreeID(from: repositories) 412 + } 413 + if !failures.isEmpty { 414 + let message = failures.joined(separator: "\n") 415 + removeRepositoryError = RemoveRepositoryError( 416 + id: UUID(), 417 + title: "Unable to remove repository", 418 + message: message 348 419 ) 349 420 } 350 421 }