native macOS codings agent orchestrator
6
fork

Configure Feed

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

Merge pull request #200 from supabitapp/sbertix/script-exit

Keep terminal alive after blocking script completes

authored by

Stefano Bertagno and committed by
GitHub
7ade80a1 6f8a9aa2

+411 -292
+3 -1
supacode/Clients/Terminal/TerminalClient.swift
··· 10 10 case createTabWithInput(Worktree, input: String, runSetupScriptIfNew: Bool) 11 11 case ensureInitialTab(Worktree, runSetupScriptIfNew: Bool, focusing: Bool) 12 12 case stopRunScript(Worktree) 13 + case selectTab(Worktree, tabId: TerminalTabID) 13 14 case runBlockingScript(Worktree, kind: BlockingScriptKind, script: String) 14 15 case closeFocusedTab(Worktree) 15 16 case closeFocusedSurface(Worktree) ··· 31 32 case tabClosed(worktreeID: Worktree.ID) 32 33 case focusChanged(worktreeID: Worktree.ID, surfaceID: UUID) 33 34 case taskStatusChanged(worktreeID: Worktree.ID, status: WorktreeTaskStatus) 34 - case blockingScriptCompleted(worktreeID: Worktree.ID, kind: BlockingScriptKind, exitCode: Int?) 35 + case blockingScriptCompleted( 36 + worktreeID: Worktree.ID, kind: BlockingScriptKind, exitCode: Int?, tabId: TerminalTabID?) 35 37 case commandPaletteToggleRequested(worktreeID: Worktree.ID) 36 38 case setupScriptConsumed(worktreeID: Worktree.ID) 37 39 }
+10 -4
supacode/Features/App/Reducer/AppFeature.swift
··· 228 228 await terminalClient.send(.runBlockingScript(worktree, kind: kind, script: script)) 229 229 } 230 230 231 + case .repositories(.delegate(.selectTerminalTab(let worktreeID, let tabId))): 232 + guard let worktree = state.repositories.worktree(for: worktreeID) else { return .none } 233 + return .run { _ in 234 + await terminalClient.send(.selectTab(worktree, tabId: tabId)) 235 + } 236 + 231 237 case .settings(.setSelection(let selection)): 232 238 let resolvedSelection = selection ?? .general 233 239 switch resolvedSelection { ··· 675 681 case .terminalEvent(.setupScriptConsumed(let worktreeID)): 676 682 return .send(.repositories(.consumeSetupScript(worktreeID))) 677 683 678 - case .terminalEvent(.blockingScriptCompleted(let worktreeID, let kind, let exitCode)): 684 + case .terminalEvent(.blockingScriptCompleted(let worktreeID, let kind, let exitCode, let tabId)): 679 685 switch kind { 680 686 case .run: 681 - return .send(.repositories(.runScriptCompleted(worktreeID: worktreeID, exitCode: exitCode))) 687 + return .send(.repositories(.runScriptCompleted(worktreeID: worktreeID, exitCode: exitCode, tabId: tabId))) 682 688 case .archive: 683 - return .send(.repositories(.archiveScriptCompleted(worktreeID: worktreeID, exitCode: exitCode))) 689 + return .send(.repositories(.archiveScriptCompleted(worktreeID: worktreeID, exitCode: exitCode, tabId: tabId))) 684 690 case .delete: 685 - return .send(.repositories(.deleteScriptCompleted(worktreeID: worktreeID, exitCode: exitCode))) 691 + return .send(.repositories(.deleteScriptCompleted(worktreeID: worktreeID, exitCode: exitCode, tabId: tabId))) 686 692 } 687 693 688 694 case .terminalEvent:
+53 -12
supacode/Features/Repositories/Reducer/RepositoriesFeature.swift
··· 198 198 ) 199 199 case consumeSetupScript(Worktree.ID) 200 200 case consumeTerminalFocus(Worktree.ID) 201 - case runScriptCompleted(worktreeID: Worktree.ID, exitCode: Int?) 201 + case runScriptCompleted(worktreeID: Worktree.ID, exitCode: Int?, tabId: TerminalTabID?) 202 202 case requestArchiveWorktree(Worktree.ID, Repository.ID) 203 203 case requestArchiveWorktrees([ArchiveWorktreeTarget]) 204 204 case archiveWorktreeConfirmed(Worktree.ID, Repository.ID) 205 - case archiveScriptCompleted(worktreeID: Worktree.ID, exitCode: Int?) 205 + case archiveScriptCompleted(worktreeID: Worktree.ID, exitCode: Int?, tabId: TerminalTabID?) 206 206 case archiveWorktreeApply(Worktree.ID, Repository.ID) 207 207 case unarchiveWorktree(Worktree.ID) 208 208 case requestDeleteWorktree(Worktree.ID, Repository.ID) 209 209 case requestDeleteWorktrees([DeleteWorktreeTarget]) 210 210 case deleteWorktreeConfirmed(Worktree.ID, Repository.ID) 211 - case deleteScriptCompleted(worktreeID: Worktree.ID, exitCode: Int?) 211 + case deleteScriptCompleted(worktreeID: Worktree.ID, exitCode: Int?, tabId: TerminalTabID?) 212 212 case deleteWorktreeApply(Worktree.ID, Repository.ID) 213 213 case worktreeDeleted( 214 214 Worktree.ID, ··· 285 285 case confirmDeleteWorktree(Worktree.ID, Repository.ID) 286 286 case confirmDeleteWorktrees([DeleteWorktreeTarget]) 287 287 case confirmRemoveRepository(Repository.ID) 288 + case viewTerminalTab(Worktree.ID, tabId: TerminalTabID) 288 289 } 289 290 290 291 enum PullRequestAction: Equatable { ··· 305 306 case openRepositorySettings(Repository.ID) 306 307 case worktreeCreated(Worktree) 307 308 case runBlockingScript(Worktree, repositoryID: Repository.ID, kind: BlockingScriptKind, script: String) 309 + case selectTerminalTab(Worktree.ID, tabId: TerminalTabID) 308 310 } 309 311 310 312 @Dependency(AnalyticsClient.self) private var analyticsClient ··· 1371 1373 } 1372 1374 ) 1373 1375 1374 - case .runScriptCompleted(let worktreeID, _): 1376 + case .runScriptCompleted(let worktreeID, let exitCode, let tabId): 1375 1377 guard state.runScriptWorktreeIDs.contains(worktreeID) else { 1376 1378 repositoriesLogger.debug("Ignoring runScriptCompleted for \(worktreeID): not in runScriptWorktreeIDs") 1377 1379 return .none 1378 1380 } 1379 1381 state.runScriptWorktreeIDs.remove(worktreeID) 1382 + guard let exitCode, exitCode != 0 else { return .none } 1383 + state.alert = blockingScriptFailureAlert( 1384 + kind: .run, exitCode: exitCode, worktreeID: worktreeID, tabId: tabId, state: state 1385 + ) 1380 1386 return .none 1381 1387 1382 1388 case .archiveWorktreeConfirmed(let worktreeID, let repositoryID): ··· 1400 1406 return .send( 1401 1407 .delegate(.runBlockingScript(worktree, repositoryID: repositoryID, kind: .archive, script: script))) 1402 1408 1403 - case .archiveScriptCompleted(let worktreeID, let exitCode): 1409 + case .archiveScriptCompleted(let worktreeID, let exitCode, let tabId): 1404 1410 guard state.archivingWorktreeIDs.contains(worktreeID) else { 1405 1411 repositoriesLogger.debug("Ignoring archiveScriptCompleted for \(worktreeID): not in archivingWorktreeIDs") 1406 1412 return .none ··· 1424 1430 repositoriesLogger.debug("Archive script cancelled or tab closed for worktree \(worktreeID)") 1425 1431 return .none 1426 1432 case let code?: 1427 - state.alert = messageAlert( 1428 - title: "Archive script failed", 1429 - message: "\(blockingScriptExitMessage(code))\nCheck the Archive Script tab for details." 1433 + state.alert = blockingScriptFailureAlert( 1434 + kind: .archive, exitCode: code, worktreeID: worktreeID, tabId: tabId, state: state 1430 1435 ) 1431 1436 return .none 1432 1437 } ··· 1652 1657 return .send( 1653 1658 .delegate(.runBlockingScript(worktree, repositoryID: repositoryID, kind: .delete, script: script))) 1654 1659 1655 - case .deleteScriptCompleted(let worktreeID, let exitCode): 1660 + case .deleteScriptCompleted(let worktreeID, let exitCode, let tabId): 1656 1661 guard state.deleteScriptWorktreeIDs.contains(worktreeID) else { 1657 1662 repositoriesLogger.debug("Ignoring deleteScriptCompleted for \(worktreeID): not in deleteScriptWorktreeIDs") 1658 1663 return .none ··· 1676 1681 repositoriesLogger.debug("Delete script cancelled or tab closed for worktree \(worktreeID)") 1677 1682 return .none 1678 1683 case let code?: 1679 - state.alert = messageAlert( 1680 - title: "Delete script failed", 1681 - message: "\(blockingScriptExitMessage(code))\nCheck the Delete Script tab for details." 1684 + state.alert = blockingScriptFailureAlert( 1685 + kind: .delete, exitCode: code, worktreeID: worktreeID, tabId: tabId, state: state 1682 1686 ) 1683 1687 return .none 1684 1688 } ··· 2661 2665 case .openRepositorySettings(let repositoryID): 2662 2666 return .send(.delegate(.openRepositorySettings(repositoryID))) 2663 2667 2668 + case .alert(.presented(.viewTerminalTab(let worktreeID, let tabId))): 2669 + return .merge( 2670 + .send(.selectWorktree(worktreeID, focusTerminal: true)), 2671 + .send(.delegate(.selectTerminalTab(worktreeID, tabId: tabId))) 2672 + ) 2673 + 2664 2674 case .alert(.dismiss): 2665 2675 state.alert = nil 2666 2676 return .none ··· 2889 2899 didPruneWorktreeOrder: didPruneWorktreeOrder, 2890 2900 didPruneArchivedWorktreeIDs: didPruneArchivedWorktreeIDs 2891 2901 ) 2902 + } 2903 + 2904 + private func blockingScriptFailureAlert( 2905 + kind: BlockingScriptKind, 2906 + exitCode: Int, 2907 + worktreeID: Worktree.ID, 2908 + tabId: TerminalTabID?, 2909 + state: State 2910 + ) -> AlertState<Alert> { 2911 + let worktreeName = state.worktree(for: worktreeID)?.name 2912 + let repoName = state.repositoryID(containing: worktreeID) 2913 + .flatMap { state.repositories[id: $0]?.name } 2914 + let parts = [repoName, worktreeName].compactMap(\.self) 2915 + if parts.isEmpty { 2916 + repositoriesLogger.debug("blockingScriptFailureAlert: worktree \(worktreeID) not found in state") 2917 + } 2918 + let subtitle = parts.isEmpty ? "Unknown worktree" : parts.joined(separator: " — ") 2919 + return AlertState { 2920 + TextState("\(kind.tabTitle) failed") 2921 + } actions: { 2922 + if let tabId { 2923 + ButtonState(action: .viewTerminalTab(worktreeID, tabId: tabId)) { 2924 + TextState("View Terminal") 2925 + } 2926 + } 2927 + ButtonState(role: .cancel) { 2928 + TextState("Dismiss") 2929 + } 2930 + } message: { 2931 + TextState("\(subtitle)\n\n\(blockingScriptExitMessage(exitCode))") 2932 + } 2892 2933 } 2893 2934 2894 2935 private func messageAlert(title: String, message: String) -> AlertState<Alert> {
+4 -2
supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift
··· 44 44 state.ensureInitialTab(focusing: focusing) 45 45 case .stopRunScript(let worktree): 46 46 _ = state(for: worktree).stopRunScript() 47 + case .selectTab(let worktree, let tabId): 48 + state(for: worktree).selectTab(tabId) 47 49 case .runBlockingScript(let worktree, let kind, let script): 48 50 _ = state(for: worktree).runBlockingScript(kind: kind, script) 49 51 case .closeFocusedTab(let worktree): ··· 159 161 state.onTaskStatusChanged = { [weak self] status in 160 162 self?.emit(.taskStatusChanged(worktreeID: worktree.id, status: status)) 161 163 } 162 - state.onBlockingScriptCompleted = { [weak self] kind, exitCode in 163 - self?.emit(.blockingScriptCompleted(worktreeID: worktree.id, kind: kind, exitCode: exitCode)) 164 + state.onBlockingScriptCompleted = { [weak self] kind, exitCode, tabId in 165 + self?.emit(.blockingScriptCompleted(worktreeID: worktree.id, kind: kind, exitCode: exitCode, tabId: tabId)) 164 166 } 165 167 state.onCommandPaletteToggle = { [weak self] in 166 168 self?.emit(.commandPaletteToggleRequested(worktreeID: worktree.id))
+42 -65
supacode/Features/Terminal/Models/WorktreeTerminalState.swift
··· 26 26 var tabIsRunningById: [TerminalTabID: Bool] = [:] 27 27 private var blockingScripts: [TerminalTabID: BlockingScriptKind] = [:] 28 28 private var blockingScriptLaunchDirectories: [TerminalTabID: URL] = [:] 29 - private var blockingScriptCommandFinished: Set<TerminalTabID> = [] 30 - private var blockingScriptLastCommandExitCode: [TerminalTabID: Int] = [:] 31 29 private var lastBlockingScriptTabByKind: [BlockingScriptKind: TerminalTabID] = [:] 32 30 private var pendingSetupScript: Bool 33 31 private var isEnsuringInitialTab = false ··· 47 45 var onTabClosed: (() -> Void)? 48 46 var onFocusChanged: ((UUID) -> Void)? 49 47 var onTaskStatusChanged: ((WorktreeTaskStatus) -> Void)? 50 - var onBlockingScriptCompleted: ((BlockingScriptKind, Int?) -> Void)? 48 + var onBlockingScriptCompleted: ((BlockingScriptKind, Int?, TerminalTabID?) -> Void)? 51 49 var onCommandPaletteToggle: (() -> Void)? 52 50 var onSetupScriptConsumed: (() -> Void)? 53 51 ··· 152 150 launch = prepared 153 151 } catch { 154 152 blockingScriptLogger.warning("Failed to prepare \(kind.tabTitle) for worktree \(worktree.id): \(error)") 155 - onBlockingScriptCompleted?(kind, nil) 153 + onBlockingScriptCompleted?(kind, 1, nil) 156 154 return nil 157 155 } 158 156 // Close any previous tab of the same kind (active or lingering ··· 160 158 // so closeTab doesn't fire a premature completion callback. 161 159 if let active = blockingScripts.first(where: { $0.value == kind })?.key { 162 160 blockingScripts.removeValue(forKey: active) 163 - blockingScriptCommandFinished.remove(active) 164 - blockingScriptLastCommandExitCode.removeValue(forKey: active) 165 161 lastBlockingScriptTabByKind.removeValue(forKey: kind) 166 162 closeTab(active) 167 163 } else if let lingering = lastBlockingScriptTabByKind.removeValue(forKey: kind) { ··· 182 178 guard let tabId else { 183 179 cleanupBlockingScriptLaunchDirectory(at: launch.directoryURL) 184 180 blockingScriptLogger.warning("Failed to create \(kind.tabTitle) tab for worktree \(worktree.id)") 185 - onBlockingScriptCompleted?(kind, nil) 181 + onBlockingScriptCompleted?(kind, 1, nil) 186 182 return nil 187 183 } 188 184 blockingScripts[tabId] = kind ··· 226 222 } 227 223 228 224 func selectTab(_ tabId: TerminalTabID) { 225 + guard tabManager.tabs.contains(where: { $0.id == tabId }) else { 226 + blockingScriptLogger.warning("selectTab: tab \(tabId.rawValue) not found in worktree \(worktree.id)") 227 + return 228 + } 229 229 tabManager.selectTab(tabId) 230 230 focusSurface(in: tabId) 231 231 emitTaskStatusIfChanged() ··· 364 364 emitTaskStatusIfChanged() 365 365 366 366 if let closedBlockingKind { 367 - blockingScriptCommandFinished.remove(tabId) 368 - blockingScriptLastCommandExitCode.removeValue(forKey: tabId) 369 367 blockingScriptLogger.info("\(closedBlockingKind.tabTitle) cancelled (tab closed)") 370 - onBlockingScriptCompleted?(closedBlockingKind, nil) 368 + onBlockingScriptCompleted?(closedBlockingKind, nil, nil) 371 369 } 372 370 onTabClosed?() 373 371 } ··· 541 539 tabIsRunningById.removeAll() 542 540 let pendingKinds = Set(blockingScripts.values) 543 541 blockingScripts.removeAll() 544 - blockingScriptCommandFinished.removeAll() 545 - blockingScriptLastCommandExitCode.removeAll() 546 542 lastBlockingScriptTabByKind.removeAll() 547 543 548 544 for kind in pendingKinds { 549 - onBlockingScriptCompleted?(kind, nil) 545 + onBlockingScriptCompleted?(kind, nil, nil) 550 546 } 551 547 tabManager.closeAll() 552 548 } ··· 649 645 ) 650 646 } 651 647 652 - // Detects signal-based termination (e.g. Ctrl+C = exit code 130) 653 - // and reports failure immediately without waiting for SHOW_CHILD_EXITED, 654 - // since the exit code is already available from COMMAND_FINISHED. 648 + // Fires when the blocking command finishes. The shell stays alive 649 + // so the user can inspect output. Completion is reported here for 650 + // all exit codes. `handleBlockingScriptChildExited` covers the 651 + // separate case where the shell exits before the command finishes. 655 652 private func handleBlockingScriptCommandFinished(tabId: TerminalTabID, exitCode: Int?) { 656 - guard let kind = blockingScripts[tabId] else { return } 657 - blockingScriptCommandFinished.insert(tabId) 658 - if let exitCode { 659 - blockingScriptLastCommandExitCode[tabId] = exitCode 660 - } 661 - guard let exitCode, exitCode >= 128 else { return } 662 - blockingScriptLogger.info("\(kind.tabTitle) interrupted by signal (exit code \(exitCode))") 663 - blockingScripts.removeValue(forKey: tabId) 664 - blockingScriptCommandFinished.remove(tabId) 665 - blockingScriptLastCommandExitCode.removeValue(forKey: tabId) 666 - tabManager.unlockAndUpdateTitle(tabId, title: "\(worktree.name) \(nextTabIndex())") 667 - 668 - Task { @MainActor [weak self] in 669 - // Bail out if a new script of the same kind started before this ran. 670 - guard self?.blockingScripts.values.contains(kind) != true else { 671 - blockingScriptLogger.info("\(kind.tabTitle) completion superseded by new script of same kind") 672 - return 673 - } 674 - self?.onBlockingScriptCompleted?(kind, exitCode) 675 - } 653 + guard let kind = blockingScripts.removeValue(forKey: tabId) else { return } 654 + blockingScriptLogger.info("\(kind.tabTitle) finished with exit code \(exitCode.map(String.init) ?? "nil")") 655 + completeBlockingScript(kind, tabId: tabId, exitCode: exitCode, reportedTabId: tabId) 676 656 } 677 657 678 - // Fires when the shell process exits. The completion callback is dispatched 679 - // asynchronously to avoid reentrancy into Ghostty's callback during surface teardown. 658 + // Fires when the shell process exits on its own (e.g. user types 659 + // exit or presses Ctrl+D). If the command already finished, this 660 + // is a no-op because `blockingScripts[tabId]` was cleared in 661 + // `handleBlockingScriptCommandFinished`. Otherwise the script was 662 + // interrupted before completing, so we treat it as cancellation. 680 663 private func handleBlockingScriptChildExited(tabId: TerminalTabID, exitCode: UInt32) { 681 664 guard let kind = blockingScripts.removeValue(forKey: tabId) else { return } 665 + blockingScriptLogger.info("\(kind.tabTitle) cancelled (shell exited before command finished)") 666 + completeBlockingScript(kind, tabId: tabId, exitCode: nil, reportedTabId: nil) 667 + } 668 + 669 + // Unlocks the tab and asynchronously fires the completion callback, 670 + // unless a new script of the same kind has already started. 671 + private func completeBlockingScript( 672 + _ kind: BlockingScriptKind, 673 + tabId: TerminalTabID, 674 + exitCode: Int?, 675 + reportedTabId: TerminalTabID? 676 + ) { 682 677 tabManager.unlockAndUpdateTitle(tabId, title: "\(worktree.name) \(nextTabIndex())") 683 678 684 - guard blockingScriptCommandFinished.remove(tabId) != nil else { 685 - blockingScriptLastCommandExitCode.removeValue(forKey: tabId) 686 - // No command ran to completion — user pressed Ctrl+D or 687 - // the shell exited before the script ran. Treat as cancellation. 688 - blockingScriptLogger.info("\(kind.tabTitle) cancelled (no command finished before child exit)") 689 - Task { @MainActor [weak self] in 690 - guard self?.blockingScripts.values.contains(kind) != true else { 691 - blockingScriptLogger.info("\(kind.tabTitle) completion superseded by new script of same kind") 692 - return 693 - } 694 - self?.onBlockingScriptCompleted?(kind, nil) 679 + Task { @MainActor [weak self] in 680 + guard let self else { 681 + blockingScriptLogger.debug("\(kind.tabTitle) completion dropped (state deallocated)") 682 + return 695 683 } 696 - return 697 - } 698 - let code = blockingScriptLastCommandExitCode.removeValue(forKey: tabId) ?? Int(exitCode) 699 - blockingScriptLogger.info("\(kind.tabTitle) completed with exit code \(code)") 700 - Task { @MainActor [weak self] in 701 - // Bail out if a new script of the same kind started before this ran. 702 - guard self?.blockingScripts.values.contains(kind) != true else { 684 + guard !self.blockingScripts.values.contains(kind) else { 703 685 blockingScriptLogger.info("\(kind.tabTitle) completion superseded by new script of same kind") 704 686 return 705 687 } 706 - self?.onBlockingScriptCompleted?(kind, code) 707 - if code == 0, self?.trees[tabId] != nil { 708 - self?.closeTab(tabId) 709 - } 688 + self.onBlockingScriptCompleted?(kind, exitCode, reportedTabId) 710 689 } 711 690 } 712 691 ··· 1018 997 cleanupBlockingScriptLaunchDirectory(for: tabId) 1019 998 tabManager.closeTab(tabId) 1020 999 if let kind = blockingScripts.removeValue(forKey: tabId) { 1021 - blockingScriptCommandFinished.remove(tabId) 1022 - blockingScriptLastCommandExitCode.removeValue(forKey: tabId) 1023 1000 lastBlockingScriptTabByKind.removeValue(forKey: kind) 1024 1001 1025 - onBlockingScriptCompleted?(kind, nil) 1002 + onBlockingScriptCompleted?(kind, nil, nil) 1026 1003 } else { 1027 1004 for (kind, tracked) in lastBlockingScriptTabByKind where tracked == tabId { 1028 1005 lastBlockingScriptTabByKind.removeValue(forKey: kind) ··· 1171 1148 rootPathURL: rootPathURL, 1172 1149 worktreePathURL: worktreePathURL, 1173 1150 shellPathURL: shellPathURL, 1174 - commandInput: shellSingleQuoted(runnerURL.path(percentEncoded: false)) + "\nexit\n" 1151 + commandInput: shellSingleQuoted(runnerURL.path(percentEncoded: false)) + "\n" 1175 1152 ) 1176 1153 } 1177 1154 ··· 1193 1170 IFS= read -r SUPACODE_WORKTREE_PATH < \(quotedWorktreePath) 1194 1171 IFS= read -r SUPACODE_SHELL_PATH < \(quotedShellPath) 1195 1172 export SUPACODE_ROOT_PATH SUPACODE_WORKTREE_PATH 1196 - exec "$SUPACODE_SHELL_PATH" -l \(quotedScriptPath) 1173 + "$SUPACODE_SHELL_PATH" -l \(quotedScriptPath) 1197 1174 """ 1198 1175 } 1199 1176
+217 -177
supacodeTests/RepositoriesFeatureTests.swift
··· 1713 1713 await store.receive(\.delegate.runBlockingScript) 1714 1714 } 1715 1715 1716 - @Test(.dependencies) func archiveScriptCompletedSuccessArchivesWorktree() async { 1716 + @Test(.dependencies) func runScriptCompletedWithFailureShowsAlert() async { 1717 1717 let repoRoot = "/tmp/repo" 1718 - let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 1719 - let featureWorktree = makeWorktree( 1720 - id: "\(repoRoot)/feature", 1721 - name: "feature", 1722 - repoRoot: repoRoot 1723 - ) 1724 - let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 1718 + let worktree = makeWorktree(id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot) 1719 + let repository = makeRepository(id: repoRoot, worktrees: [worktree]) 1725 1720 var state = makeState(repositories: [repository]) 1726 - state.archivingWorktreeIDs = [featureWorktree.id] 1721 + state.runScriptWorktreeIDs = [worktree.id] 1727 1722 let store = TestStore(initialState: state) { 1728 1723 RepositoriesFeature() 1729 1724 } 1730 1725 1731 - await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: 0)) { 1732 - $0.archivingWorktreeIDs = [] 1726 + await store.send(.runScriptCompleted(worktreeID: worktree.id, exitCode: 1, tabId: nil)) { 1727 + $0.runScriptWorktreeIDs = [] 1728 + $0.alert = expectedScriptFailureAlert( 1729 + kind: .run, 1730 + exitMessage: "Script failed (exit code 1).", 1731 + worktreeID: worktree.id, 1732 + repoName: "repo", 1733 + worktreeName: "feature" 1734 + ) 1735 + } 1736 + } 1737 + 1738 + @Test(.dependencies) func runScriptCompletedWithSuccessDoesNotShowAlert() async { 1739 + let repoRoot = "/tmp/repo" 1740 + let worktree = makeWorktree(id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot) 1741 + let repository = makeRepository(id: repoRoot, worktrees: [worktree]) 1742 + var state = makeState(repositories: [repository]) 1743 + state.runScriptWorktreeIDs = [worktree.id] 1744 + let store = TestStore(initialState: state) { 1745 + RepositoriesFeature() 1733 1746 } 1734 - await store.receive(\.archiveWorktreeApply) { 1735 - $0.archivedWorktreeIDs = [featureWorktree.id] 1747 + 1748 + await store.send(.runScriptCompleted(worktreeID: worktree.id, exitCode: 0, tabId: nil)) { 1749 + $0.runScriptWorktreeIDs = [] 1736 1750 } 1737 - await store.receive(\.delegate.repositoriesChanged) 1751 + #expect(store.state.alert == nil) 1738 1752 } 1739 1753 1740 - @Test(.dependencies) func archiveScriptCompletedFailureShowsAlert() async { 1754 + @Test(.dependencies) func runScriptCompletedWithNilExitCodeDoesNotShowAlert() async { 1741 1755 let repoRoot = "/tmp/repo" 1742 - let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 1743 - let featureWorktree = makeWorktree( 1744 - id: "\(repoRoot)/feature", 1745 - name: "feature", 1746 - repoRoot: repoRoot 1747 - ) 1748 - let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 1756 + let worktree = makeWorktree(id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot) 1757 + let repository = makeRepository(id: repoRoot, worktrees: [worktree]) 1749 1758 var state = makeState(repositories: [repository]) 1750 - state.archivingWorktreeIDs = [featureWorktree.id] 1759 + state.runScriptWorktreeIDs = [worktree.id] 1751 1760 let store = TestStore(initialState: state) { 1752 1761 RepositoriesFeature() 1753 1762 } 1754 1763 1755 - let expectedAlert = AlertState<RepositoriesFeature.Alert> { 1756 - TextState("Archive script failed") 1757 - } actions: { 1758 - ButtonState(role: .cancel) { 1759 - TextState("OK") 1760 - } 1761 - } message: { 1762 - TextState("Script exited with code 7.\nCheck the Archive Script tab for details.") 1764 + await store.send(.runScriptCompleted(worktreeID: worktree.id, exitCode: nil, tabId: nil)) { 1765 + $0.runScriptWorktreeIDs = [] 1763 1766 } 1767 + #expect(store.state.alert == nil) 1768 + } 1764 1769 1765 - await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: 7)) { 1766 - $0.archivingWorktreeIDs = [] 1767 - $0.alert = expectedAlert 1770 + @Test(.dependencies) func viewTerminalTabSelectsWorktreeAndDelegatesTabSelection() async { 1771 + let testID = UUID().uuidString 1772 + let repoRoot = "/tmp/\(testID)-repo" 1773 + let worktree = makeWorktree(id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot) 1774 + let repository = makeRepository(id: repoRoot, worktrees: [worktree]) 1775 + let tabId = TerminalTabID() 1776 + var state = makeState(repositories: [repository]) 1777 + state.runScriptWorktreeIDs = [worktree.id] 1778 + let store = TestStore(initialState: state) { 1779 + RepositoriesFeature() 1780 + } 1781 + store.exhaustivity = .off 1782 + 1783 + // Trigger the failure alert through the normal flow. 1784 + await store.send(.runScriptCompleted(worktreeID: worktree.id, exitCode: 1, tabId: tabId)) { 1785 + $0.runScriptWorktreeIDs = [] 1786 + $0.alert = expectedScriptFailureAlert( 1787 + kind: .run, 1788 + exitMessage: "Script failed (exit code 1).", 1789 + worktreeID: worktree.id, 1790 + tabId: tabId, 1791 + repoName: repository.name, 1792 + worktreeName: "feature" 1793 + ) 1768 1794 } 1769 - #expect(store.state.archivedWorktreeIDs.isEmpty) 1795 + 1796 + // Tap "View Terminal". 1797 + await store.send(.alert(.presented(.viewTerminalTab(worktree.id, tabId: tabId)))) 1798 + await store.receive(\.selectWorktree) 1799 + await store.receive(\.delegate.selectTerminalTab) 1800 + await store.receive(\.delegate.selectedWorktreeChanged) 1770 1801 } 1771 1802 1772 - @Test func archiveScriptCompletedCancellationClearsState() async { 1803 + @Test(.dependencies) func archiveScriptFailureWithTabIdShowsViewTerminalButton() async { 1773 1804 let repoRoot = "/tmp/repo" 1774 1805 let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 1775 1806 let featureWorktree = makeWorktree( ··· 1778 1809 repoRoot: repoRoot 1779 1810 ) 1780 1811 let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 1812 + let tabId = TerminalTabID() 1781 1813 var state = makeState(repositories: [repository]) 1782 1814 state.archivingWorktreeIDs = [featureWorktree.id] 1783 1815 let store = TestStore(initialState: state) { 1784 1816 RepositoriesFeature() 1785 1817 } 1786 1818 1787 - await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: nil)) { 1819 + await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: 1, tabId: tabId)) { 1788 1820 $0.archivingWorktreeIDs = [] 1821 + $0.alert = expectedScriptFailureAlert( 1822 + kind: .archive, 1823 + exitMessage: "Script failed (exit code 1).", 1824 + worktreeID: featureWorktree.id, 1825 + tabId: tabId, 1826 + repoName: "repo", 1827 + worktreeName: "feature" 1828 + ) 1789 1829 } 1790 - #expect(store.state.alert == nil) 1791 - #expect(store.state.archivedWorktreeIDs.isEmpty) 1792 1830 } 1793 1831 1794 - @Test func archiveScriptCompletedIgnoredWhenNotArchiving() async { 1832 + @Test(.dependencies) func deleteScriptFailureWithTabIdShowsViewTerminalButton() async { 1795 1833 let repoRoot = "/tmp/repo" 1796 1834 let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 1797 1835 let featureWorktree = makeWorktree( ··· 1800 1838 repoRoot: repoRoot 1801 1839 ) 1802 1840 let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 1803 - let store = TestStore(initialState: makeState(repositories: [repository])) { 1841 + let tabId = TerminalTabID() 1842 + var state = makeState(repositories: [repository]) 1843 + state.deleteScriptWorktreeIDs = [featureWorktree.id] 1844 + let store = TestStore(initialState: state) { 1804 1845 RepositoriesFeature() 1805 1846 } 1806 1847 1807 - await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: 0)) 1808 - #expect(store.state.archivedWorktreeIDs.isEmpty) 1848 + await store.send(.deleteScriptCompleted(worktreeID: featureWorktree.id, exitCode: 1, tabId: tabId)) { 1849 + $0.deleteScriptWorktreeIDs = [] 1850 + $0.alert = expectedScriptFailureAlert( 1851 + kind: .delete, 1852 + exitMessage: "Script failed (exit code 1).", 1853 + worktreeID: featureWorktree.id, 1854 + tabId: tabId, 1855 + repoName: "repo", 1856 + worktreeName: "feature" 1857 + ) 1858 + } 1809 1859 } 1810 1860 1811 - @Test func repositoriesLoadedKeepsArchiveInFlightUntilSuccessCompletion() async { 1861 + @Test(.dependencies) func archiveScriptCompletedSuccessArchivesWorktree() async { 1812 1862 let repoRoot = "/tmp/repo" 1813 1863 let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 1814 1864 let featureWorktree = makeWorktree( ··· 1817 1867 repoRoot: repoRoot 1818 1868 ) 1819 1869 let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 1820 - let reloadedRepository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) 1821 1870 var state = makeState(repositories: [repository]) 1822 1871 state.archivingWorktreeIDs = [featureWorktree.id] 1823 1872 let store = TestStore(initialState: state) { 1824 1873 RepositoriesFeature() 1825 1874 } 1826 - store.exhaustivity = .off 1827 1875 1828 - await store.send( 1829 - .repositoriesLoaded( 1830 - [reloadedRepository], 1831 - failures: [], 1832 - roots: [repository.rootURL], 1833 - animated: false 1834 - ) 1835 - ) 1836 - #expect(store.state.archivingWorktreeIDs.contains(featureWorktree.id)) 1837 - 1838 - await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: 0)) 1839 - #expect(store.state.archivingWorktreeIDs.isEmpty) 1876 + await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: 0, tabId: nil)) { 1877 + $0.archivingWorktreeIDs = [] 1878 + } 1879 + await store.receive(\.archiveWorktreeApply) { 1880 + $0.archivedWorktreeIDs = [featureWorktree.id] 1881 + } 1882 + await store.receive(\.delegate.repositoriesChanged) 1840 1883 } 1841 1884 1842 - @Test func repositoriesLoadedKeepsArchiveInFlightUntilFailureCompletion() async { 1885 + @Test(.dependencies) func archiveScriptCompletedFailureShowsAlert() async { 1843 1886 let repoRoot = "/tmp/repo" 1844 1887 let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 1845 1888 let featureWorktree = makeWorktree( ··· 1848 1891 repoRoot: repoRoot 1849 1892 ) 1850 1893 let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 1851 - let reloadedRepository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) 1852 1894 var state = makeState(repositories: [repository]) 1853 1895 state.archivingWorktreeIDs = [featureWorktree.id] 1854 1896 let store = TestStore(initialState: state) { 1855 1897 RepositoriesFeature() 1856 1898 } 1857 - store.exhaustivity = .off 1858 1899 1859 - await store.send( 1860 - .repositoriesLoaded( 1861 - [reloadedRepository], 1862 - failures: [], 1863 - roots: [repository.rootURL], 1864 - animated: false 1900 + await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: 7, tabId: nil)) { 1901 + $0.archivingWorktreeIDs = [] 1902 + $0.alert = expectedScriptFailureAlert( 1903 + kind: .archive, 1904 + exitMessage: "Script exited with code 7.", 1905 + worktreeID: featureWorktree.id, 1906 + repoName: "repo", 1907 + worktreeName: "feature" 1865 1908 ) 1866 - ) 1867 - #expect(store.state.archivingWorktreeIDs.contains(featureWorktree.id)) 1868 - 1869 - await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: 1)) 1870 - #expect(store.state.archivingWorktreeIDs.isEmpty) 1871 - #expect(store.state.alert != nil) 1909 + } 1910 + #expect(store.state.archivedWorktreeIDs.isEmpty) 1872 1911 } 1873 1912 1874 - // MARK: - Archive script exit code coverage 1875 - 1876 - @Test func archiveScriptCompletedExitCode1ShowsGenericFailure() async { 1913 + @Test func archiveScriptCompletedCancellationClearsState() async { 1877 1914 let repoRoot = "/tmp/repo" 1878 1915 let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 1879 1916 let featureWorktree = makeWorktree( ··· 1888 1925 RepositoriesFeature() 1889 1926 } 1890 1927 1891 - let expectedAlert = AlertState<RepositoriesFeature.Alert> { 1892 - TextState("Archive script failed") 1893 - } actions: { 1894 - ButtonState(role: .cancel) { 1895 - TextState("OK") 1896 - } 1897 - } message: { 1898 - TextState("Script failed (exit code 1).\nCheck the Archive Script tab for details.") 1899 - } 1900 - 1901 - await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: 1)) { 1928 + await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: nil, tabId: nil)) { 1902 1929 $0.archivingWorktreeIDs = [] 1903 - $0.alert = expectedAlert 1904 1930 } 1931 + #expect(store.state.alert == nil) 1905 1932 #expect(store.state.archivedWorktreeIDs.isEmpty) 1906 1933 } 1907 1934 1908 - @Test func archiveScriptCompletedExitCode126ShowsPermissionDenied() async { 1935 + @Test func archiveScriptCompletedIgnoredWhenNotArchiving() async { 1909 1936 let repoRoot = "/tmp/repo" 1910 1937 let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 1911 1938 let featureWorktree = makeWorktree( ··· 1914 1941 repoRoot: repoRoot 1915 1942 ) 1916 1943 let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 1917 - var state = makeState(repositories: [repository]) 1918 - state.archivingWorktreeIDs = [featureWorktree.id] 1919 - let store = TestStore(initialState: state) { 1944 + let store = TestStore(initialState: makeState(repositories: [repository])) { 1920 1945 RepositoriesFeature() 1921 1946 } 1922 1947 1923 - let expectedAlert = AlertState<RepositoriesFeature.Alert> { 1924 - TextState("Archive script failed") 1925 - } actions: { 1926 - ButtonState(role: .cancel) { 1927 - TextState("OK") 1928 - } 1929 - } message: { 1930 - TextState("Permission denied (exit code 126).\nCheck the Archive Script tab for details.") 1931 - } 1932 - 1933 - await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: 126)) { 1934 - $0.archivingWorktreeIDs = [] 1935 - $0.alert = expectedAlert 1936 - } 1948 + await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: 0, tabId: nil)) 1949 + #expect(store.state.archivedWorktreeIDs.isEmpty) 1937 1950 } 1938 1951 1939 - @Test func archiveScriptCompletedExitCode127ShowsCommandNotFound() async { 1952 + @Test func repositoriesLoadedKeepsArchiveInFlightUntilSuccessCompletion() async { 1940 1953 let repoRoot = "/tmp/repo" 1941 1954 let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 1942 1955 let featureWorktree = makeWorktree( ··· 1945 1958 repoRoot: repoRoot 1946 1959 ) 1947 1960 let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 1961 + let reloadedRepository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) 1948 1962 var state = makeState(repositories: [repository]) 1949 1963 state.archivingWorktreeIDs = [featureWorktree.id] 1950 1964 let store = TestStore(initialState: state) { 1951 1965 RepositoriesFeature() 1952 1966 } 1967 + store.exhaustivity = .off 1953 1968 1954 - let expectedAlert = AlertState<RepositoriesFeature.Alert> { 1955 - TextState("Archive script failed") 1956 - } actions: { 1957 - ButtonState(role: .cancel) { 1958 - TextState("OK") 1959 - } 1960 - } message: { 1961 - TextState("Command not found (exit code 127).\nCheck the Archive Script tab for details.") 1962 - } 1969 + await store.send( 1970 + .repositoriesLoaded( 1971 + [reloadedRepository], 1972 + failures: [], 1973 + roots: [repository.rootURL], 1974 + animated: false 1975 + ) 1976 + ) 1977 + #expect(store.state.archivingWorktreeIDs.contains(featureWorktree.id)) 1963 1978 1964 - await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: 127)) { 1965 - $0.archivingWorktreeIDs = [] 1966 - $0.alert = expectedAlert 1967 - } 1979 + await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: 0, tabId: nil)) 1980 + #expect(store.state.archivingWorktreeIDs.isEmpty) 1968 1981 } 1969 1982 1970 - @Test func archiveScriptCompletedExitCode130ShowsSignalKilled() async { 1983 + @Test func repositoriesLoadedKeepsArchiveInFlightUntilFailureCompletion() async { 1971 1984 let repoRoot = "/tmp/repo" 1972 1985 let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 1973 1986 let featureWorktree = makeWorktree( ··· 1976 1989 repoRoot: repoRoot 1977 1990 ) 1978 1991 let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 1992 + let reloadedRepository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) 1979 1993 var state = makeState(repositories: [repository]) 1980 1994 state.archivingWorktreeIDs = [featureWorktree.id] 1981 1995 let store = TestStore(initialState: state) { 1982 1996 RepositoriesFeature() 1983 1997 } 1998 + store.exhaustivity = .off 1984 1999 1985 - let expectedAlert = AlertState<RepositoriesFeature.Alert> { 1986 - TextState("Archive script failed") 1987 - } actions: { 1988 - ButtonState(role: .cancel) { 1989 - TextState("OK") 1990 - } 1991 - } message: { 1992 - TextState("Script killed by signal 2 (exit code 130).\nCheck the Archive Script tab for details.") 1993 - } 2000 + await store.send( 2001 + .repositoriesLoaded( 2002 + [reloadedRepository], 2003 + failures: [], 2004 + roots: [repository.rootURL], 2005 + animated: false 2006 + ) 2007 + ) 2008 + #expect(store.state.archivingWorktreeIDs.contains(featureWorktree.id)) 1994 2009 1995 - await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: 130)) { 1996 - $0.archivingWorktreeIDs = [] 1997 - $0.alert = expectedAlert 1998 - } 2010 + await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: 1, tabId: nil)) 2011 + #expect(store.state.archivingWorktreeIDs.isEmpty) 2012 + #expect(store.state.alert != nil) 1999 2013 } 2000 2014 2001 - @Test func archiveScriptCompletedExitCode137ShowsSIGKILL() async { 2015 + // MARK: - Archive script exit code coverage 2016 + 2017 + nonisolated static let archiveExitCodeCases: [(Int, String)] = [ 2018 + (1, "Script failed (exit code 1)."), 2019 + (126, "Permission denied (exit code 126)."), 2020 + (127, "Command not found (exit code 127)."), 2021 + (130, "Script killed by signal 2 (exit code 130)."), 2022 + (137, "Script killed by signal 9 (exit code 137)."), 2023 + ] 2024 + 2025 + @Test(arguments: archiveExitCodeCases) 2026 + func archiveScriptCompletedShowsExpectedMessage(exitCode: Int, expectedMessage: String) async { 2002 2027 let repoRoot = "/tmp/repo" 2003 2028 let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 2004 2029 let featureWorktree = makeWorktree( ··· 2013 2038 RepositoriesFeature() 2014 2039 } 2015 2040 2016 - let expectedAlert = AlertState<RepositoriesFeature.Alert> { 2017 - TextState("Archive script failed") 2018 - } actions: { 2019 - ButtonState(role: .cancel) { 2020 - TextState("OK") 2021 - } 2022 - } message: { 2023 - TextState("Script killed by signal 9 (exit code 137).\nCheck the Archive Script tab for details.") 2024 - } 2025 - 2026 - await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: 137)) { 2041 + await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: exitCode, tabId: nil)) { 2027 2042 $0.archivingWorktreeIDs = [] 2028 - $0.alert = expectedAlert 2043 + $0.alert = expectedScriptFailureAlert( 2044 + kind: .archive, 2045 + exitMessage: expectedMessage, 2046 + worktreeID: featureWorktree.id, 2047 + repoName: "repo", 2048 + worktreeName: "feature", 2049 + ) 2029 2050 } 2051 + #expect(store.state.archivedWorktreeIDs.isEmpty) 2030 2052 } 2031 2053 2032 2054 @Test(.dependencies) func archiveWorktreeConfirmedEmptyScriptSkipsToApply() async { ··· 2069 2091 } 2070 2092 2071 2093 // Exit code 1 must NOT trigger archiveWorktreeApply. 2072 - await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: 1)) { 2094 + await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: 1, tabId: nil)) { 2073 2095 $0.archivingWorktreeIDs = [] 2074 - $0.alert = AlertState { 2075 - TextState("Archive script failed") 2076 - } actions: { 2077 - ButtonState(role: .cancel) { 2078 - TextState("OK") 2079 - } 2080 - } message: { 2081 - TextState("Script failed (exit code 1).\nCheck the Archive Script tab for details.") 2082 - } 2096 + $0.alert = expectedScriptFailureAlert( 2097 + kind: .archive, 2098 + exitMessage: "Script failed (exit code 1).", 2099 + worktreeID: featureWorktree.id, 2100 + repoName: "repo", 2101 + worktreeName: "feature" 2102 + ) 2083 2103 } 2084 2104 #expect(store.state.archivedWorktreeIDs.isEmpty) 2085 2105 } ··· 2100 2120 } 2101 2121 2102 2122 // Nil exit code (Ctrl+D, tab close) must NOT trigger archiveWorktreeApply. 2103 - await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: nil)) { 2123 + await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: nil, tabId: nil)) { 2104 2124 $0.archivingWorktreeIDs = [] 2105 2125 } 2106 2126 #expect(store.state.archivedWorktreeIDs.isEmpty) ··· 2126 2146 } 2127 2147 store.exhaustivity = .off 2128 2148 2129 - await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: exitCode)) 2149 + await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: exitCode, tabId: nil)) 2130 2150 #expect( 2131 2151 store.state.archivedWorktreeIDs.isEmpty, 2132 2152 "Exit code \(exitCode) should NOT archive the worktree" ··· 2184 2204 $0.gitClient.worktrees = { _ in [mainWorktree] } 2185 2205 } 2186 2206 2187 - await store.send(.deleteScriptCompleted(worktreeID: featureWorktree.id, exitCode: 0)) { 2207 + await store.send(.deleteScriptCompleted(worktreeID: featureWorktree.id, exitCode: 0, tabId: nil)) { 2188 2208 $0.deleteScriptWorktreeIDs = [] 2189 2209 } 2190 2210 await store.receive(\.deleteWorktreeApply) { ··· 2216 2236 RepositoriesFeature() 2217 2237 } 2218 2238 2219 - let expectedAlert = AlertState<RepositoriesFeature.Alert> { 2220 - TextState("Delete script failed") 2221 - } actions: { 2222 - ButtonState(role: .cancel) { 2223 - TextState("OK") 2224 - } 2225 - } message: { 2226 - TextState("Script exited with code 7.\nCheck the Delete Script tab for details.") 2227 - } 2228 - 2229 - await store.send(.deleteScriptCompleted(worktreeID: featureWorktree.id, exitCode: 7)) { 2239 + await store.send(.deleteScriptCompleted(worktreeID: featureWorktree.id, exitCode: 7, tabId: nil)) { 2230 2240 $0.deleteScriptWorktreeIDs = [] 2231 - $0.alert = expectedAlert 2241 + $0.alert = expectedScriptFailureAlert( 2242 + kind: .delete, 2243 + exitMessage: "Script exited with code 7.", 2244 + worktreeID: featureWorktree.id, 2245 + repoName: "repo", 2246 + worktreeName: "feature" 2247 + ) 2232 2248 } 2233 2249 } 2234 2250 ··· 2247 2263 RepositoriesFeature() 2248 2264 } 2249 2265 2250 - await store.send(.deleteScriptCompleted(worktreeID: featureWorktree.id, exitCode: nil)) { 2266 + await store.send(.deleteScriptCompleted(worktreeID: featureWorktree.id, exitCode: nil, tabId: nil)) { 2251 2267 $0.deleteScriptWorktreeIDs = [] 2252 2268 } 2253 2269 #expect(store.state.alert == nil) ··· 2266 2282 RepositoriesFeature() 2267 2283 } 2268 2284 2269 - await store.send(.deleteScriptCompleted(worktreeID: featureWorktree.id, exitCode: 0)) 2285 + await store.send(.deleteScriptCompleted(worktreeID: featureWorktree.id, exitCode: 0, tabId: nil)) 2270 2286 } 2271 2287 2272 2288 @Test(.dependencies) func deleteWorktreeConfirmedSkipsScriptWhenEmpty() async { ··· 2329 2345 ) 2330 2346 } 2331 2347 2332 - await store.send(.deleteScriptCompleted(worktreeID: "/tmp/repo/gone", exitCode: 0)) { 2348 + await store.send(.deleteScriptCompleted(worktreeID: "/tmp/repo/gone", exitCode: 0, tabId: nil)) { 2333 2349 $0.deleteScriptWorktreeIDs = [] 2334 2350 $0.alert = expectedAlert 2335 2351 } ··· 2380 2396 ) 2381 2397 #expect(store.state.deleteScriptWorktreeIDs.contains(featureWorktree.id)) 2382 2398 2383 - await store.send(.deleteScriptCompleted(worktreeID: featureWorktree.id, exitCode: 0)) 2399 + await store.send(.deleteScriptCompleted(worktreeID: featureWorktree.id, exitCode: 0, tabId: nil)) 2384 2400 #expect(store.state.deleteScriptWorktreeIDs.isEmpty) 2385 2401 } 2386 2402 ··· 2411 2427 ) 2412 2428 #expect(store.state.deleteScriptWorktreeIDs.contains(featureWorktree.id)) 2413 2429 2414 - await store.send(.deleteScriptCompleted(worktreeID: featureWorktree.id, exitCode: 1)) 2430 + await store.send(.deleteScriptCompleted(worktreeID: featureWorktree.id, exitCode: 1, tabId: nil)) 2415 2431 #expect(store.state.deleteScriptWorktreeIDs.isEmpty) 2416 2432 #expect(store.state.alert != nil) 2417 2433 } ··· 3700 3716 name: name, 3701 3717 worktrees: IdentifiedArray(uniqueElements: worktrees) 3702 3718 ) 3719 + } 3720 + 3721 + private func expectedScriptFailureAlert( 3722 + kind: BlockingScriptKind, 3723 + exitMessage: String, 3724 + worktreeID: Worktree.ID, 3725 + tabId: TerminalTabID? = nil, 3726 + repoName: String, 3727 + worktreeName: String 3728 + ) -> AlertState<RepositoriesFeature.Alert> { 3729 + AlertState { 3730 + TextState("\(kind.tabTitle) failed") 3731 + } actions: { 3732 + if let tabId { 3733 + ButtonState(action: .viewTerminalTab(worktreeID, tabId: tabId)) { 3734 + TextState("View Terminal") 3735 + } 3736 + } 3737 + ButtonState(role: .cancel) { 3738 + TextState("Dismiss") 3739 + } 3740 + } message: { 3741 + TextState("\(repoName) — \(worktreeName)\n\n\(exitMessage)") 3742 + } 3703 3743 } 3704 3744 3705 3745 private func makeState(repositories: [Repository]) -> RepositoriesFeature.State {
+4 -3
supacodeTests/WorktreeEnvironmentTests.swift
··· 95 95 launch.directoryURL.deletingLastPathComponent().path(percentEncoded: false) 96 96 == FileManager.default.temporaryDirectory.path(percentEncoded: false) 97 97 ) 98 - #expect(launch.commandInput == shellSingleQuoted(launch.runnerURL.path(percentEncoded: false)) + "\nexit\n") 98 + #expect(launch.commandInput == shellSingleQuoted(launch.runnerURL.path(percentEncoded: false)) + "\n") 99 99 #expect(scriptContents == "docker compose down\ncodex exec \"test\"\n") 100 100 #expect(rootPathContents == "/tmp/repo\n") 101 101 #expect(worktreePathContents == "/tmp/repo/wt-1\n") ··· 120 120 ) 121 121 #expect( 122 122 runnerContents.contains( 123 - "exec \"$SUPACODE_SHELL_PATH\" -l \(shellSingleQuoted(launch.scriptURL.path(percentEncoded: false)))" 123 + "\"$SUPACODE_SHELL_PATH\" -l \(shellSingleQuoted(launch.scriptURL.path(percentEncoded: false)))" 124 124 ) 125 125 == true 126 126 ) 127 + #expect(runnerContents.contains("exec") == false) 127 128 #expect(runnerContents.contains("docker compose down") == false) 128 129 #expect(runnerContents.contains("codex exec \"test\"") == false) 129 130 } ··· 142 143 #expect( 143 144 try makeBlockingScriptLaunch( 144 145 script: """ 145 - 146 + 146 147 """, 147 148 environment: [ 148 149 "SUPACODE_ROOT_PATH": "/tmp/repo",
+78 -28
supacodeTests/WorktreeTerminalManagerTests.swift
··· 55 55 title: "Unread", 56 56 body: "body", 57 57 isRead: false 58 - ) 58 + ), 59 59 ] 60 60 state.onNotificationIndicatorChanged?() 61 61 state.notifications = [ ··· 64 64 title: "Read", 65 65 body: "body", 66 66 isRead: true 67 - ) 67 + ), 68 68 ] 69 69 70 70 let stream = manager.eventStream() ··· 202 202 #expect(manager.hasUnseenNotifications(for: worktree.id) == false) 203 203 } 204 204 205 - @Test func blockingScriptCompletionPrefersCommandFinishedExitCode() async { 205 + @Test func blockingScriptCompletionReportsExitCodeFromCommandFinished() async { 206 206 let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 207 207 let worktree = makeWorktree() 208 208 let stream = manager.eventStream() ··· 218 218 } 219 219 220 220 surface.bridge.onCommandFinished?(1) 221 - surface.bridge.onChildExited?(0) 222 221 223 222 let event = await nextEvent(stream) { event in 224 223 if case .blockingScriptCompleted = event { ··· 227 226 return false 228 227 } 229 228 230 - #expect(event == .blockingScriptCompleted(worktreeID: worktree.id, kind: .archive, exitCode: 1)) 229 + #expect(event == .blockingScriptCompleted(worktreeID: worktree.id, kind: .archive, exitCode: 1, tabId: tabId)) 231 230 } 232 231 233 - @Test func blockingScriptCompletionUsesLatestCommandFinishedExitCode() async { 232 + @Test func blockingScriptCompletionPassesNilExitCodeWhenCommandFinishedReportsNil() async { 234 233 let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 235 234 let worktree = makeWorktree() 236 235 let stream = manager.eventStream() ··· 245 244 return 246 245 } 247 246 248 - surface.bridge.onCommandFinished?(0) 249 - surface.bridge.onCommandFinished?(1) 250 - surface.bridge.onChildExited?(0) 247 + surface.bridge.onCommandFinished?(nil) 251 248 252 249 let event = await nextEvent(stream) { event in 253 250 if case .blockingScriptCompleted = event { ··· 256 253 return false 257 254 } 258 255 259 - #expect(event == .blockingScriptCompleted(worktreeID: worktree.id, kind: .archive, exitCode: 1)) 256 + #expect(event == .blockingScriptCompleted(worktreeID: worktree.id, kind: .archive, exitCode: nil, tabId: tabId)) 260 257 } 261 258 262 - @Test func blockingScriptCompletionFallsBackToChildExitCodeWhenCommandFinishedNil() async { 259 + @Test func blockingScriptCommandFinishedFollowedByChildExitDoesNotDoubleFire() async { 263 260 let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 264 261 let worktree = makeWorktree() 265 262 let stream = manager.eventStream() ··· 274 271 return 275 272 } 276 273 277 - surface.bridge.onCommandFinished?(nil) 278 - surface.bridge.onChildExited?(23) 274 + // Normal flow: command finishes, then shell exits later. 275 + surface.bridge.onCommandFinished?(0) 276 + surface.bridge.onChildExited?(0) 279 277 278 + // First completion event should arrive. 280 279 let event = await nextEvent(stream) { event in 281 280 if case .blockingScriptCompleted = event { 282 281 return true 283 282 } 284 283 return false 285 284 } 285 + #expect(event == .blockingScriptCompleted(worktreeID: worktree.id, kind: .archive, exitCode: 0, tabId: tabId)) 286 286 287 - #expect(event == .blockingScriptCompleted(worktreeID: worktree.id, kind: .archive, exitCode: 23)) 287 + // The child exit should NOT produce a second completion. 288 + #expect(!manager.isBlockingScriptRunning(kind: .archive, for: worktree.id)) 288 289 } 289 290 290 291 @Test func blockingScriptChildExitWithoutCommandFinishedIsCancellation() async { ··· 311 312 return false 312 313 } 313 314 314 - #expect(event == .blockingScriptCompleted(worktreeID: worktree.id, kind: .archive, exitCode: nil)) 315 + #expect(event == .blockingScriptCompleted(worktreeID: worktree.id, kind: .archive, exitCode: nil, tabId: nil)) 315 316 } 316 317 317 318 @Test func blockingScriptSignalBasedTerminationReportsImmediately() async { ··· 340 341 return false 341 342 } 342 343 343 - #expect(event == .blockingScriptCompleted(worktreeID: worktree.id, kind: .archive, exitCode: 130)) 344 + #expect(event == .blockingScriptCompleted(worktreeID: worktree.id, kind: .archive, exitCode: 130, tabId: tabId)) 344 345 } 345 346 346 347 @Test func blockingScriptRerunClosesOldTabWithoutFiringCompletion() async { ··· 374 375 return 375 376 } 376 377 surface.bridge.onCommandFinished?(0) 377 - surface.bridge.onChildExited?(0) 378 378 379 379 let event = await nextEvent(stream) { event in 380 380 if case .blockingScriptCompleted = event { ··· 383 383 return false 384 384 } 385 385 386 - #expect(event == .blockingScriptCompleted(worktreeID: worktree.id, kind: .archive, exitCode: 0)) 386 + #expect(event == .blockingScriptCompleted(worktreeID: worktree.id, kind: .archive, exitCode: 0, tabId: secondTabId)) 387 387 } 388 388 389 389 @Test func blockingScriptTabClosedManuallyReportsCancellation() async { ··· 410 410 return false 411 411 } 412 412 413 - #expect(event == .blockingScriptCompleted(worktreeID: worktree.id, kind: .archive, exitCode: nil)) 413 + #expect(event == .blockingScriptCompleted(worktreeID: worktree.id, kind: .archive, exitCode: nil, tabId: nil)) 414 414 } 415 415 416 416 @Test func closeAllSurfacesCancelsPendingBlockingScripts() async { ··· 434 434 return false 435 435 } 436 436 437 - #expect(event == .blockingScriptCompleted(worktreeID: worktree.id, kind: .archive, exitCode: nil)) 437 + #expect(event == .blockingScriptCompleted(worktreeID: worktree.id, kind: .archive, exitCode: nil, tabId: nil)) 438 438 } 439 439 440 - @Test func blockingScriptSuccessAutoClosesTab() async { 440 + @Test func blockingScriptSuccessKeepsTabOpen() async { 441 441 let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 442 442 let worktree = makeWorktree() 443 443 let stream = manager.eventStream() ··· 455 455 #expect(state.tabManager.tabs.map(\.id).contains(tabId)) 456 456 457 457 surface.bridge.onCommandFinished?(0) 458 - surface.bridge.onChildExited?(0) 459 458 460 459 let event = await nextEvent(stream) { event in 461 460 if case .blockingScriptCompleted = event { ··· 464 463 return false 465 464 } 466 465 467 - #expect(event == .blockingScriptCompleted(worktreeID: worktree.id, kind: .archive, exitCode: 0)) 468 - // Successful script should auto-close the tab. 469 - #expect(!state.tabManager.tabs.map(\.id).contains(tabId)) 466 + #expect(event == .blockingScriptCompleted(worktreeID: worktree.id, kind: .archive, exitCode: 0, tabId: tabId)) 467 + // Tab stays open so the user can inspect output. 468 + #expect(state.tabManager.tabs.map(\.id).contains(tabId)) 470 469 } 471 470 472 471 @Test func runScriptBlockingScriptTracksRunningState() { ··· 544 543 #expect(tab?.title == "Archive Script") 545 544 #expect(tab?.tintColor == .orange) 546 545 547 - // Title reset happens synchronously in handleBlockingScriptChildExited, 548 - // before the completion callback fires in an async Task. 546 + // Tab appearance reset happens synchronously in completeBlockingScript. 549 547 surface.bridge.onCommandFinished?(1) 550 - surface.bridge.onChildExited?(1) 551 548 552 549 let updatedTab = state.tabManager.tabs.first { $0.id == tabId } 553 550 #expect(updatedTab?.isTitleLocked == false) 554 551 #expect(updatedTab?.icon == nil) 555 552 #expect(updatedTab?.tintColor == nil) 553 + } 554 + 555 + @Test func selectTabWithValidIdChangesSelection() { 556 + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 557 + let worktree = makeWorktree() 558 + 559 + // Create two blocking script tabs so we have two tabs to switch between. 560 + manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "echo archive")) 561 + manager.handleCommand(.runBlockingScript(worktree, kind: .delete, script: "echo delete")) 562 + 563 + guard let state = manager.stateIfExists(for: worktree.id) else { 564 + Issue.record("Expected worktree state") 565 + return 566 + } 567 + 568 + let tabIds = state.tabManager.tabs.map(\.id) 569 + guard tabIds.count >= 2 else { 570 + Issue.record("Expected at least two tabs") 571 + return 572 + } 573 + let firstTabId = tabIds[0] 574 + let secondTabId = tabIds[1] 575 + 576 + // Select the second tab first. 577 + manager.handleCommand(.selectTab(worktree, tabId: secondTabId)) 578 + #expect(state.tabManager.selectedTabId == secondTabId) 579 + 580 + // Select the first tab. 581 + manager.handleCommand(.selectTab(worktree, tabId: firstTabId)) 582 + #expect(state.tabManager.selectedTabId == firstTabId) 583 + } 584 + 585 + @Test func selectTabWithStaleIdIsNoOp() { 586 + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 587 + let worktree = makeWorktree() 588 + 589 + manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "echo ok")) 590 + 591 + guard let state = manager.stateIfExists(for: worktree.id), 592 + let tabId = state.tabManager.selectedTabId 593 + else { 594 + Issue.record("Expected blocking script tab") 595 + return 596 + } 597 + 598 + // Close the tab, then try to select it by its stale ID. 599 + state.closeTab(tabId) 600 + let selectedBefore = state.tabManager.selectedTabId 601 + 602 + manager.handleCommand(.selectTab(worktree, tabId: tabId)) 603 + 604 + // Selection should not change. 605 + #expect(state.tabManager.selectedTabId == selectedBefore) 556 606 } 557 607 558 608 private func makeWorktree() -> Worktree {