native macOS codings agent orchestrator
6
fork

Configure Feed

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

Fix cross-repo script dot tint and reduce sidebar animation CPU (#265)

* Carry tint color with running-script records so worktrees outside the selected repository render the correct dot color instead of falling back to green.

`scriptsByID` only covered the selected repo's scripts, so sidebar rows for worktrees in other repos couldn't resolve their running-script tint. Shift `runningScriptsByWorktreeID` from `[Worktree.ID: Set<UUID>]` to `[Worktree.ID: [UUID: TerminalTabTintColor]]` and drop the view-side lookup.

* Migrate .repeatForever animations to phaseAnimator

PingDot was a measured CPU hotspot (~23% of main thread) because the always-on .repeatForever driver combined with a non-Equatable AnyShapeStyle rebuilt on every parent body evaluation. Swap to phaseAnimator so SwiftUI can pause when occluded, and pass a concrete Color through PingDot/PingRing instead of AnyShapeStyle.

Same treatment for the Shimmer sweep and the GhosttySurfaceProgressBar indeterminate sweep. Also drop the redundant .mask(solid black) from the inactive shimmer branch.

authored by

Stefano Bertagno and committed by
GitHub
57e620a7 539c0feb

+168 -114
-13
supacode/App/ContentView.swift
··· 33 33 } 34 34 .navigationSplitViewStyle(.automatic) 35 35 .disabled(!store.repositories.isInitialLoadComplete) 36 - .environment(\.scriptsByID, Dictionary(uniqueKeysWithValues: store.scripts.map { ($0.id, $0) })) 37 36 .environment(\.surfaceBackgroundOpacity, terminalManager.surfaceBackgroundOpacity()) 38 37 .onChange(of: scenePhase) { _, newValue in 39 38 store.send(.scenePhaseChanged(newValue)) ··· 103 102 store.send(.repositories(.revealSelectedWorktreeInSidebar)) 104 103 } 105 104 106 - } 107 - 108 - private struct ScriptsByIDEnvironmentKey: EnvironmentKey { 109 - static let defaultValue: [UUID: ScriptDefinition] = [:] 110 - } 111 - 112 - extension EnvironmentValues { 113 - /// Pre-computed lookup for sidebar row color resolution. 114 - var scriptsByID: [UUID: ScriptDefinition] { 115 - get { self[ScriptsByIDEnvironmentKey.self] } 116 - set { self[ScriptsByIDEnvironmentKey.self] = newValue } 117 - } 118 105 } 119 106 120 107 private struct SurfaceBackgroundOpacityKey: EnvironmentKey {
+2 -2
supacode/App/supacodeApp.swift
··· 338 338 return 339 339 } 340 340 @SharedReader(.repositorySettings(worktree.repositoryRootURL)) var settings 341 - let runningIDs = store.repositories.runningScriptsByWorktreeID[worktree.id] ?? [] 341 + let runningIDs = store.repositories.runningScriptsByWorktreeID[worktree.id] ?? [:] 342 342 let data = settings.scripts.map { script in 343 343 [ 344 344 "id": script.id.uuidString, 345 345 "kind": script.kind.rawValue, 346 346 "name": script.name, 347 347 "displayName": script.displayName, 348 - "running": runningIDs.contains(script.id) ? "1" : "", 348 + "running": runningIDs[script.id] != nil ? "1" : "", 349 349 ] 350 350 } 351 351 AgentHookSocketServer.sendQueryResponse(clientFD: clientFD, data: data)
+12 -9
supacode/Features/App/Reducer/AppFeature.swift
··· 46 46 47 47 /// Running script IDs for the currently selected worktree. 48 48 var runningScriptIDs: Set<UUID> { 49 - guard let worktreeID = repositories.selectedWorktreeID else { return [] } 50 - return repositories.runningScriptsByWorktreeID[worktreeID] ?? [] 49 + guard 50 + let worktreeID = repositories.selectedWorktreeID, 51 + let tints = repositories.runningScriptsByWorktreeID[worktreeID] 52 + else { return [] } 53 + return Set(tints.keys) 51 54 } 52 55 53 56 /// Whether any `.run`-kind script is currently running in the selected worktree. ··· 455 458 let trimmed = definition.command.trimmingCharacters(in: .whitespacesAndNewlines) 456 459 guard !trimmed.isEmpty else { return .none } 457 460 analyticsClient.capture("script_run", ["kind": definition.kind.rawValue]) 458 - var ids = state.repositories.runningScriptsByWorktreeID[worktree.id] ?? [] 459 - ids.insert(definition.id) 461 + var ids = state.repositories.runningScriptsByWorktreeID[worktree.id] ?? [:] 462 + ids[definition.id] = definition.resolvedTintColor 460 463 state.repositories.runningScriptsByWorktreeID[worktree.id] = ids 461 464 return .run { _ in 462 465 await terminalClient.send( ··· 1244 1247 ) 1245 1248 return .none 1246 1249 } 1247 - let runningIDs = state.repositories.runningScriptsByWorktreeID[worktreeID] ?? [] 1248 - guard !runningIDs.contains(scriptID) else { 1250 + let runningIDs = state.repositories.runningScriptsByWorktreeID[worktreeID] ?? [:] 1251 + guard runningIDs[scriptID] == nil else { 1249 1252 state.alert = scriptAlert( 1250 1253 title: "Script already running", 1251 1254 message: "\"\(definition.displayName)\" is already running in this worktree." ··· 1263 1266 } 1264 1267 analyticsClient.capture("script_run", ["kind": definition.kind.rawValue]) 1265 1268 var updated = runningIDs 1266 - updated.insert(scriptID) 1269 + updated[scriptID] = definition.resolvedTintColor 1267 1270 state.repositories.runningScriptsByWorktreeID[worktreeID] = updated 1268 1271 let terminalClient = terminalClient 1269 1272 return .run { _ in ··· 1290 1293 ) 1291 1294 return .none 1292 1295 } 1293 - let runningIDs = state.repositories.runningScriptsByWorktreeID[worktreeID] ?? [] 1294 - guard runningIDs.contains(scriptID) else { 1296 + let runningIDs = state.repositories.runningScriptsByWorktreeID[worktreeID] ?? [:] 1297 + guard runningIDs[scriptID] != nil else { 1295 1298 state.alert = scriptAlert( 1296 1299 title: "Script not running", 1297 1300 message: "\"\(definition.displayName)\" is not currently running in this worktree."
+9 -12
supacode/Features/Repositories/Reducer/RepositoriesFeature.swift
··· 94 94 var pendingWorktrees: [PendingWorktree] = [] 95 95 var pendingSetupScriptWorktreeIDs: Set<Worktree.ID> = [] 96 96 var pendingTerminalFocusWorktreeIDs: Set<Worktree.ID> = [] 97 - var runningScriptsByWorktreeID: [Worktree.ID: Set<UUID>] = [:] 97 + var runningScriptsByWorktreeID: [Worktree.ID: [UUID: TerminalTabTintColor]] = [:] 98 98 var archivingWorktreeIDs: Set<Worktree.ID> = [] 99 99 var deleteScriptWorktreeIDs: Set<Worktree.ID> = [] 100 100 var deletingWorktreeIDs: Set<Worktree.ID> = [] ··· 1425 1425 ) 1426 1426 1427 1427 case .scriptCompleted(let worktreeID, let scriptID, let kind, let exitCode, let tabId): 1428 - guard var ids = state.runningScriptsByWorktreeID[worktreeID], ids.contains(scriptID) else { 1428 + guard var ids = state.runningScriptsByWorktreeID[worktreeID], ids[scriptID] != nil else { 1429 1429 repositoriesLogger.debug("Ignoring scriptCompleted for \(worktreeID)/\(scriptID): not tracked") 1430 1430 return .none 1431 1431 } 1432 - ids.remove(scriptID) 1432 + ids.removeValue(forKey: scriptID) 1433 1433 if ids.isEmpty { 1434 1434 state.runningScriptsByWorktreeID.removeValue(forKey: worktreeID) 1435 1435 } else { ··· 3657 3657 } 3658 3658 3659 3659 /// Tint colors for scripts currently running in the given worktree, 3660 - /// ordered deterministically by script ID. Falls back to `.green` 3661 - /// for script IDs not found in the lookup (e.g. when the selected 3662 - /// worktree belongs to a different repository). 3663 - func runningScriptColors( 3664 - for worktreeID: Worktree.ID, 3665 - scriptsByID: [UUID: ScriptDefinition] 3666 - ) -> [TerminalTabTintColor] { 3667 - guard let scriptIDs = runningScriptsByWorktreeID[worktreeID] else { return [] } 3668 - return scriptIDs.sorted().map { scriptsByID[$0]?.resolvedTintColor ?? .green } 3660 + /// ordered deterministically by script ID. The tint travels alongside 3661 + /// the running script ID so the color resolves correctly even when 3662 + /// the worktree belongs to a repository other than the selected one. 3663 + func runningScriptColors(for worktreeID: Worktree.ID) -> [TerminalTabTintColor] { 3664 + guard let tintsByID = runningScriptsByWorktreeID[worktreeID] else { return [] } 3665 + return tintsByID.sorted(by: { $0.key < $1.key }).map(\.value) 3669 3666 } 3670 3667 3671 3668 func pendingWorktree(for id: Worktree.ID?) -> PendingWorktree? {
+32 -21
supacode/Features/Repositories/Views/SidebarItemView.swift
··· 457 457 let resolved = uniqueColors 458 458 if resolved.count <= 1 { 459 459 PingDot( 460 - style: resolved.first.map { AnyShapeStyle($0) } ?? AnyShapeStyle(.green), 460 + color: resolved.first ?? .green, 461 461 size: size, 462 462 showsSolidCenter: showsSolidCenter 463 463 ) ··· 497 497 let colors: [Color] 498 498 let size: CGFloat 499 499 let showsSolidCenter: Bool 500 - @State private var isPinging = false 501 500 502 501 var body: some View { 503 502 TimelineView(.periodic(from: .now, by: 2.0)) { timeline in 504 503 let index = Self.colorIndex(for: timeline.date, count: colors.count) 504 + let color = colors[index] 505 505 ZStack { 506 - Circle() 507 - .stroke(colors[index], lineWidth: 1) 508 - .frame(width: size, height: size) 509 - .scaleEffect(isPinging ? 2 : 1) 510 - .opacity(isPinging ? 0 : 0.6) 511 - .animation(.easeOut(duration: 1).repeatForever(autoreverses: false), value: isPinging) 506 + PingRing(color: color, size: size) 512 507 if showsSolidCenter { 513 508 Circle() 514 - .fill(colors[index]) 509 + .fill(color) 515 510 .frame(width: size, height: size) 516 511 } 517 512 } 518 513 .animation(.easeInOut(duration: 0.6), value: index) 519 514 } 520 515 .accessibilityLabel("Run script active") 521 - .task { isPinging = true } 522 516 } 523 517 524 518 private static func colorIndex(for date: Date, count: Int) -> Int { ··· 530 524 531 525 // MARK: - Pulsing dot. 532 526 533 - private struct PingDot<S: ShapeStyle>: View { 534 - let style: S 527 + private struct PingDot: View { 528 + let color: Color 535 529 let size: CGFloat 536 530 let showsSolidCenter: Bool 537 - @State private var isPinging = false 538 531 539 532 var body: some View { 540 533 ZStack { 541 - Circle() 542 - .stroke(style, lineWidth: 1) 543 - .frame(width: size, height: size) 544 - .scaleEffect(isPinging ? 2 : 1) 545 - .opacity(isPinging ? 0 : 0.6) 546 - .animation(.easeOut(duration: 1).repeatForever(autoreverses: false), value: isPinging) 534 + PingRing(color: color, size: size) 547 535 if showsSolidCenter { 548 536 Circle() 549 - .fill(style) 537 + .fill(color) 550 538 .frame(width: size, height: size) 551 539 } 552 540 } 553 541 .accessibilityLabel("Run script active") 554 - .task { isPinging = true } 542 + } 543 + } 544 + 545 + /// Expanding, fading ring driven by `phaseAnimator` rather than 546 + /// `.repeatForever` so SwiftUI can pause the timeline when the view 547 + /// is occluded and so parent re-evaluations don't restart the cycle. 548 + private struct PingRing: View { 549 + let color: Color 550 + let size: CGFloat 551 + 552 + var body: some View { 553 + Circle() 554 + .stroke(color, lineWidth: 1) 555 + .frame(width: size, height: size) 556 + .phaseAnimator([false, true]) { content, expanded in 557 + content 558 + .scaleEffect(expanded ? 2 : 1) 559 + .opacity(expanded ? 0 : 0.6) 560 + } animation: { expanded in 561 + // Snap back to the seed phase instantly, then ease out the 562 + // expansion — yields a non-autoreversing ping without the 563 + // always-on `.repeatForever` animation driver. 564 + expanded ? .easeOut(duration: 1) : .linear(duration: 0.001) 565 + } 555 566 } 556 567 } 557 568
+1 -2
supacode/Features/Repositories/Views/SidebarItemsView.swift
··· 167 167 let shortcutHint: String? 168 168 @Shared(.appStorage("worktreeRowDisplayMode")) private var displayMode: WorktreeRowDisplayMode = .branchFirst 169 169 @Shared(.appStorage("worktreeRowHideSubtitleOnMatch")) private var hideSubtitleOnMatch = true 170 - @Environment(\.scriptsByID) private var scriptsByID 171 170 172 171 var body: some View { 173 172 SidebarItemView( ··· 176 175 hideSubtitle: hideSubtitle, 177 176 hideSubtitleOnMatch: hideSubtitleOnMatch, 178 177 showsPullRequestInfo: !draggingWorktreeIDs.contains(row.id), 179 - runningScriptColors: store.state.runningScriptColors(for: row.id, scriptsByID: scriptsByID), 178 + runningScriptColors: store.state.runningScriptColors(for: row.id), 180 179 isTaskRunning: terminalManager.stateIfExists(for: row.id)?.taskStatus == .running, 181 180 showsNotificationIndicator: terminalManager.hasUnseenNotifications(for: row.id), 182 181 notifications: terminalManager.stateIfExists(for: row.id)?.notifications ?? [],
+5 -14
supacode/Features/Terminal/Views/GhosttySurfaceProgressBar.swift
··· 5 5 let progressState: ghostty_action_progress_report_state_e 6 6 let progressValue: Int? 7 7 8 - @State private var position: CGFloat = 0 9 - 10 8 var body: some View { 11 9 let color: Color = 12 10 switch progressState { ··· 52 50 Rectangle() 53 51 .fill(color) 54 52 .frame(width: geometry.size.width * 0.25, height: geometry.size.height) 55 - .offset(x: position * (geometry.size.width * 0.75)) 56 - } 57 - .onAppear { 58 - withAnimation( 59 - .easeInOut(duration: 1.2) 60 - .repeatForever(autoreverses: true) 61 - ) { 62 - position = 1 63 - } 64 - } 65 - .onDisappear { 66 - position = 0 53 + .phaseAnimator([false, true]) { content, moved in 54 + content.offset(x: moved ? geometry.size.width * 0.75 : 0) 55 + } animation: { _ in 56 + .easeInOut(duration: 1.2) 57 + } 67 58 } 68 59 } 69 60 }
+18 -27
supacode/Support/ShimmerModifier.swift
··· 2 2 3 3 struct ShimmerModifier: ViewModifier { 4 4 let isActive: Bool 5 - @State private var animating = false 6 5 @Environment(\.layoutDirection) private var layoutDirection 7 6 8 7 private let bandSize: CGFloat = 0.3 9 - private let animation: Animation = .linear(duration: 1.5).delay(0.25).repeatForever(autoreverses: false) 10 8 private let gradient = Gradient(colors: [ 11 9 .black.opacity(0.6), 12 10 .black, ··· 16 14 private var min: CGFloat { 0 - bandSize } 17 15 private var max: CGFloat { 1 + bandSize } 18 16 19 - private var startPoint: UnitPoint { 17 + private func startPoint(animating: Bool) -> UnitPoint { 20 18 if layoutDirection == .rightToLeft { 21 19 return animating ? UnitPoint(x: 0, y: 1) : UnitPoint(x: max, y: min) 22 20 } 23 21 return animating ? UnitPoint(x: 1, y: 1) : UnitPoint(x: min, y: min) 24 22 } 25 23 26 - private var endPoint: UnitPoint { 24 + private func endPoint(animating: Bool) -> UnitPoint { 27 25 if layoutDirection == .rightToLeft { 28 26 return animating ? UnitPoint(x: min, y: max) : UnitPoint(x: 1, y: 0) 29 27 } ··· 31 29 } 32 30 33 31 func body(content: Content) -> some View { 34 - content 35 - .mask( 36 - LinearGradient( 37 - gradient: isActive ? gradient : Gradient(colors: [.black]), 38 - startPoint: startPoint, 39 - endPoint: endPoint 32 + if isActive { 33 + // Driving the sweep via `phaseAnimator` lets SwiftUI pause the 34 + // timeline when the view is occluded — `.repeatForever` kept 35 + // the animation pipeline active even when nothing was visible. 36 + content.phaseAnimator([false, true]) { content, animating in 37 + content.mask( 38 + LinearGradient( 39 + gradient: gradient, 40 + startPoint: startPoint(animating: animating), 41 + endPoint: endPoint(animating: animating) 42 + ) 40 43 ) 41 - ) 42 - .animation(isActive ? animation : nil, value: animating) 43 - .onChange(of: isActive) { oldValue, newValue in 44 - guard oldValue != newValue else { return } 45 - if newValue { 46 - animating = false 47 - Task { @MainActor in 48 - animating = true 49 - } 50 - } else { 51 - animating = false 52 - } 44 + } animation: { animating in 45 + animating ? .linear(duration: 1.5).delay(0.25) : .linear(duration: 0.001) 53 46 } 54 - .task { 55 - guard isActive else { return } 56 - try? await Task.sleep(for: .milliseconds(50)) 57 - animating = true 58 - } 47 + } else { 48 + content 49 + } 59 50 } 60 51 } 61 52
+7 -3
supacodeTests/AppFeatureArchivedSelectionTests.swift
··· 90 90 settings: SettingsFeature.State() 91 91 ) 92 92 let scriptID = UUID() 93 + // Distinct tints per worktree so the pruner is asserted to carry 94 + // the surviving tint through untouched, not coincidentally match. 95 + let activeTint: TerminalTabTintColor = .purple 96 + let archivedTint: TerminalTabTintColor = .orange 93 97 appState.repositories.runningScriptsByWorktreeID = [ 94 - activeWorktree.id: [scriptID], 95 - archivedWorktree.id: [scriptID], 98 + activeWorktree.id: [scriptID: activeTint], 99 + archivedWorktree.id: [scriptID: archivedTint], 96 100 ] 97 101 let sentCommands = LockIsolated<[TerminalClient.Command]>([]) 98 102 let store = TestStore(initialState: appState) { ··· 106 110 store.exhaustivity = .off 107 111 108 112 await store.send(.repositories(.delegate(.repositoriesChanged([repository])))) { 109 - $0.repositories.runningScriptsByWorktreeID = [activeWorktree.id: [scriptID]] 113 + $0.repositories.runningScriptsByWorktreeID = [activeWorktree.id: [scriptID: activeTint]] 110 114 } 111 115 await store.finish() 112 116
+2 -2
supacodeTests/AppFeatureDeeplinkTests.swift
··· 296 296 $persisted.withLock { $0.scripts = [definition] } 297 297 defer { $persisted.withLock { $0.scripts = [] } } 298 298 var repositories = makeRepositoriesState(worktree: worktree) 299 - repositories.runningScriptsByWorktreeID = [worktree.id: [definition.id]] 299 + repositories.runningScriptsByWorktreeID = [worktree.id: [definition.id: definition.resolvedTintColor]] 300 300 let sent = LockIsolated<[TerminalClient.Command]>([]) 301 301 let store = TestStore( 302 302 initialState: AppFeature.State(repositories: repositories, settings: SettingsFeature.State()) ··· 403 403 $persisted.withLock { $0.scripts = [definition] } 404 404 defer { $persisted.withLock { $0.scripts = [] } } 405 405 var repositories = makeRepositoriesState(worktree: worktree) 406 - repositories.runningScriptsByWorktreeID = [worktree.id: [definition.id]] 406 + repositories.runningScriptsByWorktreeID = [worktree.id: [definition.id: definition.resolvedTintColor]] 407 407 var settings = SettingsFeature.State() 408 408 settings.automatedActionPolicy = .always 409 409 let sent = LockIsolated<[TerminalClient.Command]>([])
+5 -5
supacodeTests/AppFeatureRunScriptTests.swift
··· 53 53 54 54 await store.send(.runScript) 55 55 await store.receive(\.runNamedScript) { 56 - $0.repositories.runningScriptsByWorktreeID = [worktree.id: [definition.id]] 56 + $0.repositories.runningScriptsByWorktreeID = [worktree.id: [definition.id: definition.resolvedTintColor]] 57 57 58 58 } 59 59 await store.finish() ··· 89 89 } 90 90 91 91 await store.send(.runNamedScript(definition)) { 92 - $0.repositories.runningScriptsByWorktreeID = [worktree.id: [definition.id]] 92 + $0.repositories.runningScriptsByWorktreeID = [worktree.id: [definition.id: definition.resolvedTintColor]] 93 93 } 94 94 await store.finish() 95 95 } ··· 104 104 ) 105 105 initialState.scripts = [definition] 106 106 // Pre-populate running state to simulate an already-running script. 107 - initialState.repositories.runningScriptsByWorktreeID = [worktree.id: [definition.id]] 107 + initialState.repositories.runningScriptsByWorktreeID = [worktree.id: [definition.id: definition.resolvedTintColor]] 108 108 let sent = LockIsolated<[TerminalClient.Command]>([]) 109 109 let store = TestStore(initialState: initialState) { 110 110 AppFeature() ··· 124 124 let repositories = makeRepositoriesState(worktree: worktree) 125 125 let definition = ScriptDefinition(kind: .run, name: "Dev", command: "npm run dev") 126 126 var repositoriesState = repositories 127 - repositoriesState.runningScriptsByWorktreeID = [worktree.id: [definition.id]] 127 + repositoriesState.runningScriptsByWorktreeID = [worktree.id: [definition.id: definition.resolvedTintColor]] 128 128 129 129 let store = TestStore( 130 130 initialState: AppFeature.State( ··· 236 236 // Simulate a script that is running but has been removed from 237 237 // the settings (e.g. user deleted it while it was executing). 238 238 var repositoriesState = repositories 239 - repositoriesState.runningScriptsByWorktreeID = [worktree.id: [definition.id]] 239 + repositoriesState.runningScriptsByWorktreeID = [worktree.id: [definition.id: definition.resolvedTintColor]] 240 240 241 241 let store = TestStore( 242 242 initialState: AppFeature.State(
+75 -4
supacodeTests/RepositoriesFeatureTests.swift
··· 1995 1995 let repository = makeRepository(id: repoRoot, worktrees: [worktree]) 1996 1996 let definition = ScriptDefinition(kind: .run, name: "Run", command: "npm start") 1997 1997 var state = makeState(repositories: [repository]) 1998 - state.runningScriptsByWorktreeID = [worktree.id: [definition.id]] 1998 + state.runningScriptsByWorktreeID = [worktree.id: [definition.id: definition.resolvedTintColor]] 1999 1999 2000 2000 let store = TestStore(initialState: state) { 2001 2001 RepositoriesFeature() ··· 2028 2028 let repository = makeRepository(id: repoRoot, worktrees: [worktree]) 2029 2029 let definition = ScriptDefinition(kind: .run, name: "Run", command: "npm start") 2030 2030 var state = makeState(repositories: [repository]) 2031 - state.runningScriptsByWorktreeID = [worktree.id: [definition.id]] 2031 + state.runningScriptsByWorktreeID = [worktree.id: [definition.id: definition.resolvedTintColor]] 2032 2032 2033 2033 let store = TestStore(initialState: state) { 2034 2034 RepositoriesFeature() ··· 2055 2055 let repository = makeRepository(id: repoRoot, worktrees: [worktree]) 2056 2056 let definition = ScriptDefinition(kind: .run, name: "Run", command: "npm start") 2057 2057 var state = makeState(repositories: [repository]) 2058 - state.runningScriptsByWorktreeID = [worktree.id: [definition.id]] 2058 + state.runningScriptsByWorktreeID = [worktree.id: [definition.id: definition.resolvedTintColor]] 2059 2059 2060 2060 let store = TestStore(initialState: state) { 2061 2061 RepositoriesFeature() ··· 2076 2076 #expect(store.state.alert == nil) 2077 2077 } 2078 2078 2079 + @Test func runningScriptColorsReturnsTintForWorktreeOutsideSelectedRepo() { 2080 + // Regression: running-script tint for a worktree in a repository 2081 + // other than the selected one used to fall back to `.green` 2082 + // because the view-side `scriptsByID` lookup only carried the 2083 + // selected repo's scripts. The tint now travels with the running 2084 + // entry, so this asserts the cross-repo resolution. 2085 + let repoA = "/tmp/repo-a" 2086 + let repoB = "/tmp/repo-b" 2087 + let worktreeA = makeWorktree(id: "\(repoA)/main", name: "main", repoRoot: repoA) 2088 + let worktreeB = makeWorktree(id: "\(repoB)/main", name: "main", repoRoot: repoB) 2089 + let repositoryA = makeRepository(id: repoA, worktrees: [worktreeA]) 2090 + let repositoryB = makeRepository(id: repoB, worktrees: [worktreeB]) 2091 + var state = makeState(repositories: [repositoryA, repositoryB]) 2092 + state.selection = .worktree(worktreeA.id) 2093 + let scriptID = UUID() 2094 + state.runningScriptsByWorktreeID = [worktreeB.id: [scriptID: .purple]] 2095 + 2096 + #expect(state.runningScriptColors(for: worktreeB.id) == [.purple]) 2097 + } 2098 + 2099 + @Test func runningScriptColorsOrdersMultipleScriptsBySortedID() { 2100 + let repoRoot = "/tmp/repo" 2101 + let worktree = makeWorktree(id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot) 2102 + let repository = makeRepository(id: repoRoot, worktrees: [worktree]) 2103 + var state = makeState(repositories: [repository]) 2104 + let firstID = UUID(uuidString: "00000000-0000-0000-0000-000000000001")! 2105 + let secondID = UUID(uuidString: "00000000-0000-0000-0000-000000000002")! 2106 + state.runningScriptsByWorktreeID = [ 2107 + worktree.id: [ 2108 + secondID: .orange, 2109 + firstID: .purple, 2110 + ], 2111 + ] 2112 + 2113 + #expect(state.runningScriptColors(for: worktree.id) == [.purple, .orange]) 2114 + } 2115 + 2116 + @Test(.dependencies) func scriptCompletedPartialCompletionPreservesSurvivors() async { 2117 + let repoRoot = "/tmp/repo" 2118 + let worktree = makeWorktree(id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot) 2119 + let repository = makeRepository(id: repoRoot, worktrees: [worktree]) 2120 + let completing = ScriptDefinition(kind: .run, name: "Run", command: "npm start") 2121 + let surviving = ScriptDefinition(kind: .test, name: "Test", command: "npm test") 2122 + var state = makeState(repositories: [repository]) 2123 + state.runningScriptsByWorktreeID = [ 2124 + worktree.id: [ 2125 + completing.id: completing.resolvedTintColor, 2126 + surviving.id: surviving.resolvedTintColor, 2127 + ], 2128 + ] 2129 + 2130 + let store = TestStore(initialState: state) { 2131 + RepositoriesFeature() 2132 + } 2133 + 2134 + await store.send( 2135 + .scriptCompleted( 2136 + worktreeID: worktree.id, 2137 + scriptID: completing.id, 2138 + kind: .script(completing), 2139 + exitCode: 0, 2140 + tabId: nil 2141 + ) 2142 + ) { 2143 + $0.runningScriptsByWorktreeID = [ 2144 + worktree.id: [surviving.id: surviving.resolvedTintColor], 2145 + ] 2146 + } 2147 + #expect(store.state.alert == nil) 2148 + } 2149 + 2079 2150 @Test(.dependencies) func viewTerminalTabSelectsWorktreeAndDelegatesTabSelection() async { 2080 2151 let testID = UUID().uuidString 2081 2152 let repoRoot = "/tmp/\(testID)-repo" ··· 2084 2155 let tabId = TerminalTabID() 2085 2156 let definition = ScriptDefinition(kind: .run, name: "Run", command: "npm start") 2086 2157 var state = makeState(repositories: [repository]) 2087 - state.runningScriptsByWorktreeID = [worktree.id: [definition.id]] 2158 + state.runningScriptsByWorktreeID = [worktree.id: [definition.id: definition.resolvedTintColor]] 2088 2159 2089 2160 let store = TestStore(initialState: state) { 2090 2161 RepositoriesFeature()