native macOS codings agent orchestrator
6
fork

Configure Feed

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

Add delete script hook for worktree deletion (#171) (#178)

Add a deleteScript repository setting that runs in a dedicated terminal
tab before worktree deletion, mirroring the existing archive script
flow. Archived worktrees with a running delete script are temporarily
shown in the sidebar so the terminal tab stays accessible.

Also refactors WorktreeRowModel from separate boolean flags into a
Status enum and adds log + alert on guard failures in
archiveWorktreeApply and deleteWorktreeApply.

authored by

Stefano Bertagno and committed by
GitHub
a65d0767 61b01ce1

+490 -47
+14 -4
supacode/Domain/WorktreeRowModel.swift
··· 1 1 import Foundation 2 2 3 3 struct WorktreeRowModel: Identifiable, Hashable { 4 + enum Status: Hashable { 5 + case idle 6 + case pending 7 + case archiving 8 + case deleting(inTerminal: Bool) 9 + } 10 + 4 11 let id: String 5 12 let repositoryID: Repository.ID 6 13 let name: String ··· 8 15 let info: WorktreeInfoEntry? 9 16 let isPinned: Bool 10 17 let isMainWorktree: Bool 11 - let isPending: Bool 12 - let isArchiving: Bool 13 - let isDeleting: Bool 14 - let isRemovable: Bool 18 + let status: Status 19 + 20 + var isPending: Bool { status == .pending } 21 + var isArchiving: Bool { status == .archiving } 22 + var isDeleting: Bool { if case .deleting = status { true } else { false } } 23 + var isLoading: Bool { status != .idle } 24 + var isRemovable: Bool { status == .idle } 15 25 }
+5 -1
supacode/Features/App/Reducer/AppFeature.swift
··· 185 185 186 186 case .repositories(.delegate(.repositoriesChanged(let repositories))): 187 187 let archivedIDs = state.repositories.archivedWorktreeIDSet 188 + let deleteScriptIDs = state.repositories.deleteScriptWorktreeIDs 188 189 let ids = Set( 189 - repositories.flatMap { $0.worktrees.map(\.id) }.filter { !archivedIDs.contains($0) } 190 + repositories.flatMap { $0.worktrees.map(\.id) } 191 + .filter { !archivedIDs.contains($0) || deleteScriptIDs.contains($0) } 190 192 ) 191 193 let recencyIDs = CommandPaletteFeature.recencyRetentionIDs(from: repositories) 192 194 let worktrees = state.repositories.worktreesForInfoWatcher() ··· 692 694 switch kind { 693 695 case .archive: 694 696 return .send(.repositories(.archiveScriptCompleted(worktreeID: worktreeID, exitCode: exitCode))) 697 + case .delete: 698 + return .send(.repositories(.deleteScriptCompleted(worktreeID: worktreeID, exitCode: exitCode))) 695 699 } 696 700 697 701 case .terminalEvent:
+1 -1
supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift
··· 216 216 items.append(contentsOf: debugToastItems()) 217 217 #endif 218 218 for row in repositories.orderedWorktreeRows() { 219 - guard !row.isPending, !row.isDeleting else { continue } 219 + guard row.status == .idle else { continue } 220 220 let repositoryName = repositories.repositoryName(for: row.repositoryID) ?? "Repository" 221 221 let title = "\(repositoryName) / \(row.name)" 222 222 items.append(
+120 -18
supacode/Features/Repositories/Reducer/RepositoriesFeature.swift
··· 72 72 var pendingSetupScriptWorktreeIDs: Set<Worktree.ID> = [] 73 73 var pendingTerminalFocusWorktreeIDs: Set<Worktree.ID> = [] 74 74 var archivingWorktreeIDs: Set<Worktree.ID> = [] 75 + var deleteScriptWorktreeIDs: Set<Worktree.ID> = [] 75 76 var deletingWorktreeIDs: Set<Worktree.ID> = [] 76 77 var removingRepositoryIDs: Set<Repository.ID> = [] 77 78 var pinnedWorktreeIDs: [Worktree.ID] = [] ··· 191 192 case requestDeleteWorktree(Worktree.ID, Repository.ID) 192 193 case requestDeleteWorktrees([DeleteWorktreeTarget]) 193 194 case deleteWorktreeConfirmed(Worktree.ID, Repository.ID) 195 + case deleteScriptCompleted(worktreeID: Worktree.ID, exitCode: Int?) 196 + case deleteWorktreeApply(Worktree.ID, Repository.ID) 194 197 case worktreeDeleted( 195 198 Worktree.ID, 196 199 repositoryID: Repository.ID, ··· 1176 1179 if state.isMainWorktree(worktree) { 1177 1180 return .none 1178 1181 } 1179 - if state.deletingWorktreeIDs.contains(worktree.id) { 1182 + if state.deletingWorktreeIDs.contains(worktree.id) 1183 + || state.deleteScriptWorktreeIDs.contains(worktree.id) 1184 + { 1180 1185 return .none 1181 1186 } 1182 1187 if state.archivingWorktreeIDs.contains(worktree.id) { ··· 1217 1222 } 1218 1223 if state.isMainWorktree(worktree) 1219 1224 || state.deletingWorktreeIDs.contains(worktree.id) 1225 + || state.deleteScriptWorktreeIDs.contains(worktree.id) 1220 1226 || state.archivingWorktreeIDs.contains(worktree.id) 1221 1227 || state.isWorktreeArchived(worktree.id) 1222 1228 { ··· 1278 1284 1279 1285 case .archiveScriptCompleted(let worktreeID, let exitCode): 1280 1286 guard state.archivingWorktreeIDs.contains(worktreeID) else { 1287 + repositoriesLogger.debug("Ignoring archiveScriptCompleted for \(worktreeID): not in archivingWorktreeIDs") 1281 1288 return .none 1282 1289 } 1283 1290 state.archivingWorktreeIDs.remove(worktreeID) ··· 1296 1303 } 1297 1304 return .send(.archiveWorktreeApply(worktreeID, repositoryID)) 1298 1305 case nil: 1299 - // User cancelled or tab was closed before script completed. 1306 + repositoriesLogger.debug("Archive script cancelled or tab closed for worktree \(worktreeID)") 1300 1307 return .none 1301 1308 case let code?: 1302 1309 state.alert = messageAlert( ··· 1310 1317 guard let repository = state.repositories[id: repositoryID], 1311 1318 let worktree = repository.worktrees[id: worktreeID] 1312 1319 else { 1320 + repositoriesLogger.warning( 1321 + "archiveWorktreeApply: worktree \(worktreeID) not found in repository \(repositoryID)" 1322 + ) 1323 + state.alert = messageAlert( 1324 + title: "Archive failed", 1325 + message: "The worktree could not be found. It may have already been removed." 1326 + ) 1313 1327 return .none 1314 1328 } 1315 1329 if state.isWorktreeArchived(worktreeID) { ··· 1414 1428 if state.archivingWorktreeIDs.contains(worktree.id) { 1415 1429 return .none 1416 1430 } 1417 - if state.deletingWorktreeIDs.contains(worktree.id) { 1431 + if state.deletingWorktreeIDs.contains(worktree.id) 1432 + || state.deleteScriptWorktreeIDs.contains(worktree.id) 1433 + { 1418 1434 return .none 1419 1435 } 1420 1436 @Shared(.settingsFile) var settingsFile ··· 1452 1468 } 1453 1469 if state.isMainWorktree(worktree) 1454 1470 || state.deletingWorktreeIDs.contains(worktree.id) 1471 + || state.deleteScriptWorktreeIDs.contains(worktree.id) 1455 1472 || state.archivingWorktreeIDs.contains(worktree.id) 1456 1473 { 1457 1474 continue ··· 1501 1518 if state.archivingWorktreeIDs.contains(worktree.id) { 1502 1519 return .none 1503 1520 } 1504 - if state.deletingWorktreeIDs.contains(worktree.id) { 1521 + if state.deletingWorktreeIDs.contains(worktree.id) 1522 + || state.deleteScriptWorktreeIDs.contains(worktree.id) 1523 + { 1505 1524 return .none 1506 1525 } 1507 1526 state.alert = nil 1527 + @Shared(.repositorySettings(worktree.repositoryRootURL)) var repositorySettings 1528 + let script = repositorySettings.deleteScript 1529 + let trimmed = script.trimmingCharacters(in: .whitespacesAndNewlines) 1530 + if trimmed.isEmpty { 1531 + return .send(.deleteWorktreeApply(worktreeID, repositoryID)) 1532 + } 1533 + state.deleteScriptWorktreeIDs.insert(worktree.id) 1534 + return .send( 1535 + .delegate(.runBlockingScript(worktree, repositoryID: repositoryID, kind: .delete, script: script))) 1536 + 1537 + case .deleteScriptCompleted(let worktreeID, let exitCode): 1538 + guard state.deleteScriptWorktreeIDs.contains(worktreeID) else { 1539 + repositoriesLogger.debug("Ignoring deleteScriptCompleted for \(worktreeID): not in deleteScriptWorktreeIDs") 1540 + return .none 1541 + } 1542 + state.deleteScriptWorktreeIDs.remove(worktreeID) 1543 + switch exitCode { 1544 + case 0: 1545 + guard let repositoryID = state.repositoryID(containing: worktreeID) else { 1546 + repositoriesLogger.warning( 1547 + "Delete script succeeded but repository not found for worktree \(worktreeID)" 1548 + ) 1549 + state.alert = messageAlert( 1550 + title: "Delete failed", 1551 + message: "The delete script completed successfully, but the worktree could not be found." 1552 + + " It may have been removed." 1553 + ) 1554 + return .none 1555 + } 1556 + return .send(.deleteWorktreeApply(worktreeID, repositoryID)) 1557 + case nil: 1558 + repositoriesLogger.debug("Delete script cancelled or tab closed for worktree \(worktreeID)") 1559 + return .none 1560 + case let code?: 1561 + state.alert = messageAlert( 1562 + title: "Delete script failed", 1563 + message: "\(blockingScriptExitMessage(code))\nCheck the DELETE SCRIPT tab for details." 1564 + ) 1565 + return .none 1566 + } 1567 + 1568 + case .deleteWorktreeApply(let worktreeID, let repositoryID): 1569 + guard let repository = state.repositories[id: repositoryID], 1570 + let worktree = repository.worktrees[id: worktreeID] 1571 + else { 1572 + repositoriesLogger.warning( 1573 + "deleteWorktreeApply: worktree \(worktreeID) not found in repository \(repositoryID)" 1574 + ) 1575 + state.alert = messageAlert( 1576 + title: "Delete failed", 1577 + message: "The worktree could not be found. It may have already been removed." 1578 + ) 1579 + return .none 1580 + } 1508 1581 state.deletingWorktreeIDs.insert(worktree.id) 1509 1582 let selectionWasRemoved = state.selectedWorktreeID == worktree.id 1510 1583 let nextSelection = ··· 1546 1619 let wasArchived = state.isWorktreeArchived(worktreeID) 1547 1620 withAnimation(.easeOut(duration: 0.2)) { 1548 1621 state.deletingWorktreeIDs.remove(worktreeID) 1622 + state.deleteScriptWorktreeIDs.remove(worktreeID) 1549 1623 state.archivingWorktreeIDs.remove(worktreeID) 1550 1624 state.pendingWorktrees.removeAll { $0.id == worktreeID } 1551 1625 state.pendingSetupScriptWorktreeIDs.remove(worktreeID) ··· 2121 2195 nextMerged, 2122 2196 !state.isMainWorktree(worktree), 2123 2197 !state.isWorktreeArchived(worktreeID), 2124 - !state.deletingWorktreeIDs.contains(worktreeID) 2198 + !state.deletingWorktreeIDs.contains(worktreeID), 2199 + !state.deleteScriptWorktreeIDs.contains(worktreeID) 2125 2200 { 2126 2201 archiveWorktreeIDs.append(worktreeID) 2127 2202 } ··· 2630 2705 } 2631 2706 let availableWorktreeIDs = Set(repositories.flatMap { $0.worktrees.map(\.id) }) 2632 2707 let filteredDeletingIDs = state.deletingWorktreeIDs.intersection(availableWorktreeIDs) 2708 + let filteredDeleteScriptIDs = state.deleteScriptWorktreeIDs 2633 2709 let filteredSetupScriptIDs = state.pendingSetupScriptWorktreeIDs.filter { 2634 2710 availableWorktreeIDs.contains($0) 2635 2711 } ··· 2646 2722 state.repositories = identifiedRepositories 2647 2723 state.pendingWorktrees = filteredPendingWorktrees 2648 2724 state.deletingWorktreeIDs = filteredDeletingIDs 2725 + state.deleteScriptWorktreeIDs = filteredDeleteScriptIDs 2649 2726 state.pendingSetupScriptWorktreeIDs = filteredSetupScriptIDs 2650 2727 state.pendingTerminalFocusWorktreeIDs = filteredFocusIDs 2651 2728 state.archivingWorktreeIDs = filteredArchivingIDs ··· 2655 2732 state.repositories = identifiedRepositories 2656 2733 state.pendingWorktrees = filteredPendingWorktrees 2657 2734 state.deletingWorktreeIDs = filteredDeletingIDs 2735 + state.deleteScriptWorktreeIDs = filteredDeleteScriptIDs 2658 2736 state.pendingSetupScriptWorktreeIDs = filteredSetupScriptIDs 2659 2737 state.pendingTerminalFocusWorktreeIDs = filteredFocusIDs 2660 2738 state.archivingWorktreeIDs = filteredArchivingIDs ··· 2836 2914 } 2837 2915 2838 2916 private func makePendingWorktreeRow(_ pending: PendingWorktree) -> WorktreeRowModel { 2839 - let isDeleting = removingRepositoryIDs.contains(pending.repositoryID) 2917 + let status: WorktreeRowModel.Status = 2918 + removingRepositoryIDs.contains(pending.repositoryID) 2919 + ? .deleting(inTerminal: false) 2920 + : .pending 2840 2921 return WorktreeRowModel( 2841 2922 id: pending.id, 2842 2923 repositoryID: pending.repositoryID, ··· 2845 2926 info: worktreeInfo(for: pending.id), 2846 2927 isPinned: false, 2847 2928 isMainWorktree: false, 2848 - isPending: true, 2849 - isArchiving: false, 2850 - isDeleting: isDeleting, 2851 - isRemovable: false 2929 + status: status 2852 2930 ) 2853 2931 } 2854 2932 ··· 2858 2936 isPinned: Bool, 2859 2937 isMainWorktree: Bool 2860 2938 ) -> WorktreeRowModel { 2861 - let isDeleting = 2862 - removingRepositoryIDs.contains(repositoryID) 2863 - || deletingWorktreeIDs.contains(worktree.id) 2864 - let isArchiving = archivingWorktreeIDs.contains(worktree.id) 2939 + let status: WorktreeRowModel.Status = 2940 + if removingRepositoryIDs.contains(repositoryID) 2941 + || deletingWorktreeIDs.contains(worktree.id) 2942 + { 2943 + .deleting(inTerminal: false) 2944 + } else if deleteScriptWorktreeIDs.contains(worktree.id) { 2945 + .deleting(inTerminal: true) 2946 + } else if archivingWorktreeIDs.contains(worktree.id) { 2947 + .archiving 2948 + } else { 2949 + .idle 2950 + } 2865 2951 return WorktreeRowModel( 2866 2952 id: worktree.id, 2867 2953 repositoryID: repositoryID, ··· 2870 2956 info: worktreeInfo(for: worktree.id), 2871 2957 isPinned: isPinned, 2872 2958 isMainWorktree: isMainWorktree, 2873 - isPending: false, 2874 - isArchiving: isArchiving, 2875 - isDeleting: isDeleting, 2876 - isRemovable: !isDeleting && !isArchiving 2959 + status: status 2877 2960 ) 2878 2961 } 2879 2962 ··· 3096 3179 ) 3097 3180 ) 3098 3181 } 3182 + // Archived worktrees with a running delete script should be 3183 + // visible in the sidebar so the terminal tab is accessible. 3184 + let archivedSet = archivedWorktreeIDSet 3185 + let unpinnedIDSet = Set(unpinnedWorktrees.map(\.id)) 3186 + for worktree in repository.worktrees { 3187 + guard archivedSet.contains(worktree.id), 3188 + deleteScriptWorktreeIDs.contains(worktree.id), 3189 + !unpinnedIDSet.contains(worktree.id) 3190 + else { continue } 3191 + unpinnedRows.append( 3192 + makeWorktreeRow( 3193 + worktree, 3194 + repositoryID: repository.id, 3195 + isPinned: false, 3196 + isMainWorktree: false 3197 + ) 3198 + ) 3199 + } 3099 3200 return WorktreeRowSections( 3100 3201 main: mainRow, 3101 3202 pinned: pinnedRows, ··· 3278 3379 state.pendingSetupScriptWorktreeIDs.remove(worktreeID) 3279 3380 state.pendingTerminalFocusWorktreeIDs.remove(worktreeID) 3280 3381 state.archivingWorktreeIDs.remove(worktreeID) 3382 + state.deleteScriptWorktreeIDs.remove(worktreeID) 3281 3383 state.deletingWorktreeIDs.remove(worktreeID) 3282 3384 state.worktreeInfoByID.removeValue(forKey: worktreeID) 3283 3385 let didUpdatePinned = state.pinnedWorktreeIDs.contains(worktreeID)
+2 -2
supacode/Features/Repositories/Views/SidebarView.swift
··· 87 87 ) -> (() -> Void)? { 88 88 let targets = 89 89 rows 90 - .filter { $0.isRemovable && !$0.isMainWorktree && !$0.isDeleting && !$0.isArchiving } 90 + .filter { $0.isRemovable && !$0.isMainWorktree } 91 91 .map { 92 92 RepositoriesFeature.ArchiveWorktreeTarget( 93 93 worktreeID: $0.id, ··· 109 109 ) -> (() -> Void)? { 110 110 let targets = 111 111 rows 112 - .filter { $0.isRemovable && !$0.isDeleting } 112 + .filter { $0.isRemovable } 113 113 .map { 114 114 RepositoriesFeature.DeleteWorktreeTarget( 115 115 worktreeID: $0.id,
+8 -4
supacode/Features/Repositories/Views/WorktreeDetailView.swift
··· 391 391 ) -> WorktreeLoadingInfo? { 392 392 guard let selectedRow else { return nil } 393 393 let repositoryName = repositories.repositoryName(for: selectedRow.repositoryID) 394 - if selectedRow.isDeleting { 394 + switch selectedRow.status { 395 + case .deleting(inTerminal: false): 395 396 return WorktreeLoadingInfo( 396 397 name: selectedRow.name, 397 398 repositoryName: repositoryName, ··· 401 402 statusCommand: nil, 402 403 statusLines: [] 403 404 ) 404 - } 405 - if selectedRow.isArchiving { 406 - // The archive script runs in a terminal tab, so let the 405 + case .archiving, .deleting(inTerminal: true): 406 + // The script runs in a terminal tab, so let the 407 407 // terminal view show through instead of a loading overlay. 408 408 return nil 409 + case .idle: 410 + return nil 411 + case .pending: 412 + break 409 413 } 410 414 if selectedRow.isPending { 411 415 let pending = repositories.pendingWorktree(for: selectedWorktreeID)
+10 -12
supacode/Features/Repositories/Views/WorktreeRowsView.swift
··· 59 59 rowView( 60 60 row, 61 61 isRepositoryRemoving: isRepositoryRemoving, 62 - moveDisabled: isRepositoryRemoving || row.isDeleting || row.isArchiving, 62 + moveDisabled: isRepositoryRemoving || row.isLoading, 63 63 shortcutHint: showShortcutHints ? worktreeShortcutHint(for: shortcutIndexByID[row.id]) : nil 64 64 ) 65 65 } ··· 78 78 rowView( 79 79 row, 80 80 isRepositoryRemoving: isRepositoryRemoving, 81 - moveDisabled: isRepositoryRemoving || row.isDeleting || row.isArchiving, 81 + moveDisabled: isRepositoryRemoving || row.isLoading, 82 82 shortcutHint: showShortcutHints ? worktreeShortcutHint(for: shortcutIndexByID[row.id]) : nil 83 83 ) 84 84 } ··· 95 95 shortcutHint: String? 96 96 ) -> some View { 97 97 let showsNotificationIndicator = terminalManager.hasUnseenNotifications(for: row.id) 98 - let displayName = 99 - if row.isDeleting { 100 - "\(row.name) (deleting...)" 101 - } else if row.isArchiving { 102 - "\(row.name) (archiving...)" 103 - } else { 104 - row.name 98 + let displayName: String = 99 + switch row.status { 100 + case .deleting: "\(row.name) (deleting...)" 101 + case .archiving: "\(row.name) (archiving...)" 102 + case .idle, .pending: row.name 105 103 } 106 104 let canShowRowActions = row.isRemovable && !isRepositoryRemoving 107 105 let pinAction: (() -> Void)? = ··· 109 107 ? { togglePin(for: row.id, isPinned: row.isPinned) } 110 108 : nil 111 109 let archiveAction: (() -> Void)? = 112 - canShowRowActions && !row.isMainWorktree && !row.isArchiving 110 + canShowRowActions && !row.isMainWorktree && !row.isLoading 113 111 ? { archiveWorktree(row.id) } 114 112 : nil 115 113 let notifications = terminalManager.stateIfExists(for: row.id)?.notifications ?? [] ··· 197 195 isHovered: config.isHovered, 198 196 isPinned: row.isPinned, 199 197 isMainWorktree: row.isMainWorktree, 200 - isLoading: row.isPending || row.isArchiving || row.isDeleting, 198 + isLoading: row.isLoading, 201 199 taskStatus: taskStatus, 202 200 isRunScriptRunning: isRunScriptRunning, 203 201 showsNotificationIndicator: config.showsNotificationIndicator, ··· 224 222 let isBulkSelection = contextRows.count > 1 225 223 let archiveTargets = 226 224 contextRows 227 - .filter { !$0.isMainWorktree && !$0.isArchiving } 225 + .filter { !$0.isMainWorktree && !$0.isLoading } 228 226 .map { 229 227 RepositoriesFeature.ArchiveWorktreeTarget( 230 228 worktreeID: $0.id,
+8
supacode/Features/Settings/Models/RepositorySettings.swift
··· 3 3 nonisolated struct RepositorySettings: Codable, Equatable, Sendable { 4 4 var setupScript: String 5 5 var archiveScript: String 6 + var deleteScript: String 6 7 var runScript: String 7 8 var openActionID: String 8 9 var worktreeBaseRef: String? ··· 14 15 private enum CodingKeys: String, CodingKey { 15 16 case setupScript 16 17 case archiveScript 18 + case deleteScript 17 19 case runScript 18 20 case openActionID 19 21 case worktreeBaseRef ··· 26 28 static let `default` = RepositorySettings( 27 29 setupScript: "", 28 30 archiveScript: "", 31 + deleteScript: "", 29 32 runScript: "", 30 33 openActionID: OpenWorktreeAction.automaticSettingsID, 31 34 worktreeBaseRef: nil, ··· 38 41 init( 39 42 setupScript: String, 40 43 archiveScript: String, 44 + deleteScript: String, 41 45 runScript: String, 42 46 openActionID: String, 43 47 worktreeBaseRef: String?, ··· 48 52 ) { 49 53 self.setupScript = setupScript 50 54 self.archiveScript = archiveScript 55 + self.deleteScript = deleteScript 51 56 self.runScript = runScript 52 57 self.openActionID = openActionID 53 58 self.worktreeBaseRef = worktreeBaseRef ··· 65 70 archiveScript = 66 71 try container.decodeIfPresent(String.self, forKey: .archiveScript) 67 72 ?? Self.default.archiveScript 73 + deleteScript = 74 + try container.decodeIfPresent(String.self, forKey: .deleteScript) 75 + ?? Self.default.deleteScript 68 76 runScript = 69 77 try container.decodeIfPresent(String.self, forKey: .runScript) 70 78 ?? Self.default.runScript
+26 -5
supacode/Features/Settings/Views/RepositorySettingsView.swift
··· 151 151 Section { 152 152 ZStack(alignment: .topLeading) { 153 153 PlainTextEditor( 154 + text: settings.runScript 155 + ) 156 + .frame(minHeight: 120) 157 + if store.settings.runScript.isEmpty { 158 + Text("npm run dev") 159 + .foregroundStyle(.secondary) 160 + .padding(.leading, 6) 161 + .font(.body) 162 + .allowsHitTesting(false) 163 + } 164 + } 165 + } header: { 166 + VStack(alignment: .leading, spacing: 4) { 167 + Text("Run Script") 168 + Text("Run script launched on demand from the toolbar") 169 + .foregroundStyle(.secondary) 170 + } 171 + } 172 + Section { 173 + ZStack(alignment: .topLeading) { 174 + PlainTextEditor( 154 175 text: settings.archiveScript 155 176 ) 156 177 .frame(minHeight: 120) ··· 172 193 Section { 173 194 ZStack(alignment: .topLeading) { 174 195 PlainTextEditor( 175 - text: settings.runScript 196 + text: settings.deleteScript 176 197 ) 177 198 .frame(minHeight: 120) 178 - if store.settings.runScript.isEmpty { 179 - Text("npm run dev") 199 + if store.settings.deleteScript.isEmpty { 200 + Text("docker compose down") 180 201 .foregroundStyle(.secondary) 181 202 .padding(.leading, 6) 182 203 .font(.body) ··· 185 206 } 186 207 } header: { 187 208 VStack(alignment: .leading, spacing: 4) { 188 - Text("Run Script") 189 - Text("Run script launched on demand from the toolbar") 209 + Text("Delete Script") 210 + Text("Delete script that runs before a worktree is deleted") 190 211 .foregroundStyle(.secondary) 191 212 } 192 213 }
+3
supacode/Features/Terminal/Models/BlockingScriptKind.swift
··· 3 3 /// in `AppFeature`'s `.blockingScriptCompleted` event router. 4 4 enum BlockingScriptKind: Hashable, Sendable { 5 5 case archive 6 + case delete 6 7 7 8 var tabTitle: String { 8 9 switch self { 9 10 case .archive: return "ARCHIVE SCRIPT" 11 + case .delete: return "DELETE SCRIPT" 10 12 } 11 13 } 12 14 13 15 var tabIcon: String { 14 16 switch self { 15 17 case .archive: return "archivebox.fill" 18 + case .delete: return "trash.fill" 16 19 } 17 20 } 18 21 }
+278
supacodeTests/RepositoriesFeatureTests.swift
··· 1396 1396 } 1397 1397 } 1398 1398 1399 + // MARK: - Delete Script 1400 + 1401 + @Test(.dependencies) func deleteWorktreeConfirmedDelegatesDeleteScript() async { 1402 + let repoRoot = "/tmp/repo" 1403 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 1404 + let featureWorktree = makeWorktree( 1405 + id: "\(repoRoot)/feature", 1406 + name: "feature", 1407 + repoRoot: repoRoot 1408 + ) 1409 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 1410 + var state = makeState(repositories: [repository]) 1411 + state.selection = .worktree(mainWorktree.id) 1412 + @Shared(.repositorySettings(repository.rootURL)) var repositorySettings 1413 + $repositorySettings.withLock { 1414 + $0.deleteScript = "echo cleaning\necho done" 1415 + } 1416 + let store = TestStore(initialState: state) { 1417 + RepositoriesFeature() 1418 + } 1419 + 1420 + await store.send(.deleteWorktreeConfirmed(featureWorktree.id, repository.id)) { 1421 + $0.deleteScriptWorktreeIDs = [featureWorktree.id] 1422 + } 1423 + await store.receive(\.delegate.runBlockingScript) 1424 + } 1425 + 1426 + @Test(.dependencies) func deleteScriptCompletedSuccessProceeds() async { 1427 + let repoRoot = "/tmp/repo" 1428 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 1429 + let featureWorktree = makeWorktree( 1430 + id: "\(repoRoot)/feature", 1431 + name: "feature", 1432 + repoRoot: repoRoot 1433 + ) 1434 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 1435 + var state = makeState(repositories: [repository]) 1436 + state.selection = .worktree(mainWorktree.id) 1437 + state.deleteScriptWorktreeIDs = [featureWorktree.id] 1438 + let store = TestStore(initialState: state) { 1439 + RepositoriesFeature() 1440 + } withDependencies: { 1441 + $0.gitClient.removeWorktree = { worktree, _ in await MainActor.run { worktree.workingDirectory } } 1442 + $0.gitClient.worktrees = { _ in [mainWorktree] } 1443 + } 1444 + 1445 + await store.send(.deleteScriptCompleted(worktreeID: featureWorktree.id, exitCode: 0)) { 1446 + $0.deleteScriptWorktreeIDs = [] 1447 + } 1448 + await store.receive(\.deleteWorktreeApply) { 1449 + $0.deletingWorktreeIDs = [featureWorktree.id] 1450 + } 1451 + await store.receive(\.worktreeDeleted) { 1452 + $0.deletingWorktreeIDs = [] 1453 + $0.repositories = [makeRepository(id: repoRoot, worktrees: [mainWorktree])] 1454 + } 1455 + await store.receive(\.delegate.repositoriesChanged) 1456 + await store.receive(\.reloadRepositories) 1457 + await store.receive(\.repositoriesLoaded) { 1458 + $0.isInitialLoadComplete = true 1459 + } 1460 + } 1461 + 1462 + @Test(.dependencies) func deleteScriptCompletedFailureShowsAlert() async { 1463 + let repoRoot = "/tmp/repo" 1464 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 1465 + let featureWorktree = makeWorktree( 1466 + id: "\(repoRoot)/feature", 1467 + name: "feature", 1468 + repoRoot: repoRoot 1469 + ) 1470 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 1471 + var state = makeState(repositories: [repository]) 1472 + state.deleteScriptWorktreeIDs = [featureWorktree.id] 1473 + let store = TestStore(initialState: state) { 1474 + RepositoriesFeature() 1475 + } 1476 + 1477 + let expectedAlert = AlertState<RepositoriesFeature.Alert> { 1478 + TextState("Delete script failed") 1479 + } actions: { 1480 + ButtonState(role: .cancel) { 1481 + TextState("OK") 1482 + } 1483 + } message: { 1484 + TextState("Script exited with code 7.\nCheck the DELETE SCRIPT tab for details.") 1485 + } 1486 + 1487 + await store.send(.deleteScriptCompleted(worktreeID: featureWorktree.id, exitCode: 7)) { 1488 + $0.deleteScriptWorktreeIDs = [] 1489 + $0.alert = expectedAlert 1490 + } 1491 + } 1492 + 1493 + @Test func deleteScriptCompletedCancellationClearsState() async { 1494 + let repoRoot = "/tmp/repo" 1495 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 1496 + let featureWorktree = makeWorktree( 1497 + id: "\(repoRoot)/feature", 1498 + name: "feature", 1499 + repoRoot: repoRoot 1500 + ) 1501 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 1502 + var state = makeState(repositories: [repository]) 1503 + state.deleteScriptWorktreeIDs = [featureWorktree.id] 1504 + let store = TestStore(initialState: state) { 1505 + RepositoriesFeature() 1506 + } 1507 + 1508 + await store.send(.deleteScriptCompleted(worktreeID: featureWorktree.id, exitCode: nil)) { 1509 + $0.deleteScriptWorktreeIDs = [] 1510 + } 1511 + #expect(store.state.alert == nil) 1512 + } 1513 + 1514 + @Test func deleteScriptCompletedIgnoredWhenNotDeleting() async { 1515 + let repoRoot = "/tmp/repo" 1516 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 1517 + let featureWorktree = makeWorktree( 1518 + id: "\(repoRoot)/feature", 1519 + name: "feature", 1520 + repoRoot: repoRoot 1521 + ) 1522 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 1523 + let store = TestStore(initialState: makeState(repositories: [repository])) { 1524 + RepositoriesFeature() 1525 + } 1526 + 1527 + await store.send(.deleteScriptCompleted(worktreeID: featureWorktree.id, exitCode: 0)) 1528 + } 1529 + 1530 + @Test(.dependencies) func deleteWorktreeConfirmedSkipsScriptWhenEmpty() async { 1531 + let repoRoot = "/tmp/repo" 1532 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 1533 + let featureWorktree = makeWorktree( 1534 + id: "\(repoRoot)/feature", 1535 + name: "feature", 1536 + repoRoot: repoRoot 1537 + ) 1538 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 1539 + var state = makeState(repositories: [repository]) 1540 + state.selection = .worktree(mainWorktree.id) 1541 + @Shared(.repositorySettings(repository.rootURL)) var repositorySettings 1542 + $repositorySettings.withLock { 1543 + $0.deleteScript = " \n " 1544 + } 1545 + let store = TestStore(initialState: state) { 1546 + RepositoriesFeature() 1547 + } withDependencies: { 1548 + $0.gitClient.removeWorktree = { worktree, _ in await MainActor.run { worktree.workingDirectory } } 1549 + $0.gitClient.worktrees = { _ in [mainWorktree] } 1550 + } 1551 + 1552 + await store.send(.deleteWorktreeConfirmed(featureWorktree.id, repository.id)) 1553 + await store.receive(\.deleteWorktreeApply) { 1554 + $0.deletingWorktreeIDs = [featureWorktree.id] 1555 + } 1556 + await store.receive(\.worktreeDeleted) { 1557 + $0.deletingWorktreeIDs = [] 1558 + $0.repositories = [makeRepository(id: repoRoot, worktrees: [mainWorktree])] 1559 + } 1560 + await store.receive(\.delegate.repositoriesChanged) 1561 + await store.receive(\.reloadRepositories) 1562 + await store.receive(\.repositoriesLoaded) { 1563 + $0.isInitialLoadComplete = true 1564 + } 1565 + } 1566 + 1567 + @Test(.dependencies) func deleteScriptCompletedSuccessButWorktreeGoneShowsAlert() async { 1568 + let repoRoot = "/tmp/repo" 1569 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 1570 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) 1571 + var state = makeState(repositories: [repository]) 1572 + state.deleteScriptWorktreeIDs = ["/tmp/repo/gone"] 1573 + let store = TestStore(initialState: state) { 1574 + RepositoriesFeature() 1575 + } 1576 + 1577 + let expectedAlert = AlertState<RepositoriesFeature.Alert> { 1578 + TextState("Delete failed") 1579 + } actions: { 1580 + ButtonState(role: .cancel) { 1581 + TextState("OK") 1582 + } 1583 + } message: { 1584 + TextState( 1585 + "The delete script completed successfully, but the worktree could not be found." 1586 + + " It may have been removed." 1587 + ) 1588 + } 1589 + 1590 + await store.send(.deleteScriptCompleted(worktreeID: "/tmp/repo/gone", exitCode: 0)) { 1591 + $0.deleteScriptWorktreeIDs = [] 1592 + $0.alert = expectedAlert 1593 + } 1594 + } 1595 + 1596 + @Test func deleteWorktreeConfirmedNoopsWhenAlreadyArchiving() async { 1597 + let repoRoot = "/tmp/repo" 1598 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 1599 + let featureWorktree = makeWorktree( 1600 + id: "\(repoRoot)/feature", 1601 + name: "feature", 1602 + repoRoot: repoRoot 1603 + ) 1604 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 1605 + var state = makeState(repositories: [repository]) 1606 + state.archivingWorktreeIDs = [featureWorktree.id] 1607 + let store = TestStore(initialState: state) { 1608 + RepositoriesFeature() 1609 + } 1610 + 1611 + await store.send(.deleteWorktreeConfirmed(featureWorktree.id, repository.id)) 1612 + } 1613 + 1614 + @Test func repositoriesLoadedKeepsDeleteScriptInFlightUntilSuccessCompletion() async { 1615 + let repoRoot = "/tmp/repo" 1616 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 1617 + let featureWorktree = makeWorktree( 1618 + id: "\(repoRoot)/feature", 1619 + name: "feature", 1620 + repoRoot: repoRoot 1621 + ) 1622 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 1623 + let reloadedRepository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) 1624 + var state = makeState(repositories: [repository]) 1625 + state.deleteScriptWorktreeIDs = [featureWorktree.id] 1626 + let store = TestStore(initialState: state) { 1627 + RepositoriesFeature() 1628 + } 1629 + store.exhaustivity = .off 1630 + 1631 + await store.send( 1632 + .repositoriesLoaded( 1633 + [reloadedRepository], 1634 + failures: [], 1635 + roots: [repository.rootURL], 1636 + animated: false 1637 + ) 1638 + ) 1639 + #expect(store.state.deleteScriptWorktreeIDs.contains(featureWorktree.id)) 1640 + 1641 + await store.send(.deleteScriptCompleted(worktreeID: featureWorktree.id, exitCode: 0)) 1642 + #expect(store.state.deleteScriptWorktreeIDs.isEmpty) 1643 + } 1644 + 1645 + @Test func repositoriesLoadedKeepsDeleteScriptInFlightUntilFailureCompletion() async { 1646 + let repoRoot = "/tmp/repo" 1647 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 1648 + let featureWorktree = makeWorktree( 1649 + id: "\(repoRoot)/feature", 1650 + name: "feature", 1651 + repoRoot: repoRoot 1652 + ) 1653 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 1654 + let reloadedRepository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) 1655 + var state = makeState(repositories: [repository]) 1656 + state.deleteScriptWorktreeIDs = [featureWorktree.id] 1657 + let store = TestStore(initialState: state) { 1658 + RepositoriesFeature() 1659 + } 1660 + store.exhaustivity = .off 1661 + 1662 + await store.send( 1663 + .repositoriesLoaded( 1664 + [reloadedRepository], 1665 + failures: [], 1666 + roots: [repository.rootURL], 1667 + animated: false 1668 + ) 1669 + ) 1670 + #expect(store.state.deleteScriptWorktreeIDs.contains(featureWorktree.id)) 1671 + 1672 + await store.send(.deleteScriptCompleted(worktreeID: featureWorktree.id, exitCode: 1)) 1673 + #expect(store.state.deleteScriptWorktreeIDs.isEmpty) 1674 + #expect(store.state.alert != nil) 1675 + } 1676 + 1399 1677 @Test func requestRenameBranchWithEmptyNameShowsAlert() async { 1400 1678 let worktree = makeWorktree(id: "/tmp/wt", name: "eagle") 1401 1679 let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree])
+15
supacodeTests/RepositorySettingsKeyTests.swift
··· 66 66 #expect(reloaded.repositories[rootURL.path(percentEncoded: false)] == updated) 67 67 } 68 68 69 + @Test func decodeMissingDeleteScriptDefaultsToEmpty() throws { 70 + let data = Data( 71 + """ 72 + { 73 + "setupScript": "echo setup", 74 + "runScript": "echo run", 75 + "openActionID": "automatic" 76 + } 77 + """.utf8 78 + ) 79 + let settings = try JSONDecoder().decode(RepositorySettings.self, from: data) 80 + 81 + #expect(settings.deleteScript.isEmpty) 82 + } 83 + 69 84 @Test func decodeMissingArchiveScriptDefaultsToEmpty() throws { 70 85 let data = Data( 71 86 """