native macOS codings agent orchestrator
6
fork

Configure Feed

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

Merge pull request #190 from supabitapp/run-setup-unfocused-worktree

Run setup script for unfocused worktrees

authored by

khoi and committed by
GitHub
0103f124 bfa7507f

+311 -58
+2
supacode/Clients/Terminal/TerminalClient.swift
··· 7 7 8 8 enum Command: Equatable { 9 9 case createTab(Worktree, runSetupScriptIfNew: Bool) 10 + case ensureInitialTab(Worktree, runSetupScriptIfNew: Bool, focusing: Bool) 10 11 case runScript(Worktree, script: String) 11 12 case stopRunScript(Worktree) 12 13 case closeFocusedTab(Worktree) ··· 30 31 case focusChanged(worktreeID: Worktree.ID, surfaceID: UUID) 31 32 case taskStatusChanged(worktreeID: Worktree.ID, status: WorktreeTaskStatus) 32 33 case runScriptStatusChanged(worktreeID: Worktree.ID, isRunning: Bool) 34 + case setupScriptConsumed(worktreeID: Worktree.ID) 33 35 } 34 36 } 35 37
+18 -8
supacode/Features/App/Reducer/AppFeature.swift
··· 135 135 .send(.worktreeSettingsLoaded(settings, worktreeID: worktreeID)) 136 136 ) 137 137 138 + case .repositories(.delegate(.worktreeCreated(let worktree))): 139 + let shouldRunSetupScript = 140 + state.repositories.pendingSetupScriptWorktreeIDs.contains(worktree.id) 141 + return .run { _ in 142 + await terminalClient.send( 143 + .ensureInitialTab( 144 + worktree, 145 + runSetupScriptIfNew: shouldRunSetupScript, 146 + focusing: false 147 + ) 148 + ) 149 + } 150 + 138 151 case .repositories(.delegate(.repositoriesChanged(let repositories))): 139 152 let ids = Set(repositories.flatMap { $0.worktrees.map(\.id) }) 140 153 let worktrees = repositories.flatMap(\.worktrees) ··· 299 312 } 300 313 analyticsClient.capture("terminal_tab_created", nil) 301 314 let shouldRunSetupScript = state.repositories.pendingSetupScriptWorktreeIDs.contains(worktree.id) 302 - var effects: [Effect<Action>] = [ 303 - .run { _ in 304 - await terminalClient.send(.createTab(worktree, runSetupScriptIfNew: shouldRunSetupScript)) 305 - }, 306 - ] 307 - if shouldRunSetupScript { 308 - effects.append(.send(.repositories(.consumeSetupScript(worktree.id)))) 315 + return .run { _ in 316 + await terminalClient.send(.createTab(worktree, runSetupScriptIfNew: shouldRunSetupScript)) 309 317 } 310 - return .merge(effects) 311 318 312 319 case .runScript: 313 320 guard let worktree = state.repositories.worktree(for: state.repositories.selectedWorktreeID) else { ··· 470 477 state.runScriptStatusByWorktreeID.removeValue(forKey: worktreeID) 471 478 } 472 479 return .none 480 + 481 + case .terminalEvent(.setupScriptConsumed(let worktreeID)): 482 + return .send(.repositories(.consumeSetupScript(worktreeID))) 473 483 474 484 case .terminalEvent: 475 485 return .none
+3 -1
supacode/Features/Repositories/Reducer/RepositoriesFeature.swift
··· 122 122 case selectedWorktreeChanged(Worktree?) 123 123 case repositoriesChanged(IdentifiedArrayOf<Repository>) 124 124 case openRepositorySettings(Repository.ID) 125 + case worktreeCreated(Worktree) 125 126 } 126 127 127 128 @Dependency(\.analyticsClient) private var analyticsClient ··· 504 505 return .merge( 505 506 .send(.reloadRepositories(animated: false)), 506 507 .send(.delegate(.repositoriesChanged(state.repositories))), 507 - .send(.delegate(.selectedWorktreeChanged(state.worktree(for: state.selectedWorktreeID)))) 508 + .send(.delegate(.selectedWorktreeChanged(state.worktree(for: state.selectedWorktreeID)))), 509 + .send(.delegate(.worktreeCreated(worktree))) 508 510 ) 509 511 510 512 case .createRandomWorktreeFailed(
-3
supacode/Features/Repositories/Views/WorktreeDetailView.swift
··· 39 39 .id(selectedWorktree.id) 40 40 .frame(maxWidth: .infinity, maxHeight: .infinity) 41 41 .onAppear { 42 - if shouldRunSetupScript { 43 - store.send(.repositories(.consumeSetupScript(selectedWorktree.id))) 44 - } 45 42 if shouldFocusTerminal { 46 43 store.send(.repositories(.consumeTerminalFocus(selectedWorktree.id))) 47 44 }
+22 -1
supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift
··· 9 9 private var notificationsEnabled = true 10 10 private var lastNotificationIndicatorCount: Int? 11 11 private var eventContinuation: AsyncStream<TerminalClient.Event>.Continuation? 12 + private var pendingEvents: [TerminalClient.Event] = [] 12 13 var selectedWorktreeID: Worktree.ID? 13 14 14 15 init(runtime: GhosttyRuntime) { ··· 19 20 switch command { 20 21 case .createTab(let worktree, let runSetupScriptIfNew): 21 22 Task { createTabAsync(in: worktree, runSetupScriptIfNew: runSetupScriptIfNew) } 23 + case .ensureInitialTab(let worktree, let runSetupScriptIfNew, let focusing): 24 + let state = state(for: worktree) { runSetupScriptIfNew } 25 + state.ensureInitialTab(focusing: focusing) 22 26 case .runScript(let worktree, let script): 23 27 _ = state(for: worktree).runScript(script) 24 28 case .stopRunScript(let worktree): ··· 53 57 let (stream, continuation) = AsyncStream.makeStream(of: TerminalClient.Event.self) 54 58 eventContinuation = continuation 55 59 lastNotificationIndicatorCount = nil 60 + if !pendingEvents.isEmpty { 61 + let bufferedEvents = pendingEvents 62 + pendingEvents.removeAll() 63 + for event in bufferedEvents { 64 + if case .notificationIndicatorChanged = event { 65 + continue 66 + } 67 + continuation.yield(event) 68 + } 69 + } 56 70 emitNotificationIndicatorCountIfNeeded() 57 71 return stream 58 72 } ··· 97 111 } 98 112 state.onRunScriptStatusChanged = { [weak self] isRunning in 99 113 self?.emit(.runScriptStatusChanged(worktreeID: worktree.id, isRunning: isRunning)) 114 + } 115 + state.onSetupScriptConsumed = { [weak self] in 116 + self?.emit(.setupScriptConsumed(worktreeID: worktree.id)) 100 117 } 101 118 states[worktree.id] = state 102 119 return state ··· 168 185 } 169 186 170 187 private func emit(_ event: TerminalClient.Event) { 171 - eventContinuation?.yield(event) 188 + guard let eventContinuation else { 189 + pendingEvents.append(event) 190 + return 191 + } 192 + eventContinuation.yield(event) 172 193 } 173 194 174 195 private func emitNotificationIndicatorCountIfNeeded() {
+22 -11
supacode/Features/Terminal/Models/WorktreeTerminalState.swift
··· 18 18 private var tabIsRunningById: [TerminalTabID: Bool] = [:] 19 19 private var runScriptTabId: TerminalTabID? 20 20 private var pendingSetupScript: Bool 21 + private var isEnsuringInitialTab = false 21 22 private var lastReportedTaskStatus: WorktreeTaskStatus? 22 23 private var lastEmittedFocusSurfaceId: UUID? 23 24 var notifications: [WorktreeTerminalNotification] = [] ··· 31 32 var onFocusChanged: ((UUID) -> Void)? 32 33 var onTaskStatusChanged: ((WorktreeTaskStatus) -> Void)? 33 34 var onRunScriptStatusChanged: ((Bool) -> Void)? 35 + var onSetupScriptConsumed: (() -> Void)? 34 36 35 37 init(runtime: GhosttyRuntime, worktree: Worktree, runSetupScript: Bool = false) { 36 38 self.runtime = runtime ··· 56 58 } 57 59 58 60 func ensureInitialTab(focusing: Bool) { 59 - if tabManager.tabs.isEmpty { 60 - Task { 61 - let setupScript: String? 62 - if pendingSetupScript { 63 - setupScript = repositorySettings.setupScript 64 - } else { 65 - setupScript = nil 66 - } 67 - await MainActor.run { 61 + guard tabManager.tabs.isEmpty else { return } 62 + guard !isEnsuringInitialTab else { return } 63 + isEnsuringInitialTab = true 64 + Task { 65 + let setupScript: String? 66 + if pendingSetupScript { 67 + setupScript = repositorySettings.setupScript 68 + } else { 69 + setupScript = nil 70 + } 71 + await MainActor.run { 72 + if tabManager.tabs.isEmpty { 68 73 _ = createTab(focusing: focusing, setupScript: setupScript) 69 74 } 75 + isEnsuringInitialTab = false 70 76 } 71 77 } 72 78 } ··· 75 81 func createTab(focusing: Bool = true, setupScript: String? = nil) -> TerminalTabID? { 76 82 let title = "\(worktree.name) \(nextTabIndex())" 77 83 let resolvedInput = setupScriptInput(setupScript: setupScript) 78 - if pendingSetupScript, setupScript != nil { 84 + let shouldConsumeSetupScript = pendingSetupScript && setupScript != nil 85 + if shouldConsumeSetupScript { 79 86 pendingSetupScript = false 80 87 } 81 - return createTab( 88 + let tabId = createTab( 82 89 title: title, 83 90 icon: "terminal", 84 91 isTitleLocked: false, 85 92 initialInput: resolvedInput, 86 93 focusing: focusing 87 94 ) 95 + if shouldConsumeSetupScript, tabId != nil { 96 + onSetupScriptConsumed?() 97 + } 98 + return tabId 88 99 } 89 100 90 101 @discardableResult
+147 -31
supacodeTests/AppFeatureTerminalSetupScriptTests.swift
··· 9 9 @MainActor 10 10 struct AppFeatureTerminalSetupScriptTests { 11 11 @Test(.dependencies) func newTerminalConsumesSetupScriptAndSendsCreateTabWithFlag() async { 12 - let worktree = Worktree( 13 - id: "/tmp/repo/wt-1", 14 - name: "wt-1", 15 - detail: "detail", 16 - workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt-1"), 17 - repositoryRootURL: URL(fileURLWithPath: "/tmp/repo") 12 + let worktree = makeWorktree() 13 + let repositoriesState = makeRepositoriesState( 14 + worktree: worktree, 15 + pendingSetupScript: true, 16 + selected: true 18 17 ) 19 - let repository = Repository( 20 - id: "/tmp/repo", 21 - rootURL: URL(fileURLWithPath: "/tmp/repo"), 22 - name: "repo", 23 - worktrees: [worktree] 24 - ) 25 - var repositoriesState = RepositoriesFeature.State() 26 - repositoriesState.repositories = [repository] 27 - repositoriesState.selectedWorktreeID = worktree.id 28 - repositoriesState.pendingSetupScriptWorktreeIDs = [worktree.id] 29 18 let sent = LockIsolated<[TerminalClient.Command]>([]) 30 19 let store = TestStore( 31 20 initialState: AppFeature.State( ··· 41 30 } 42 31 43 32 await store.send(.newTerminal) 33 + await store.send(.terminalEvent(.setupScriptConsumed(worktreeID: worktree.id))) 44 34 await store.receive(\.repositories.consumeSetupScript) { 45 35 $0.repositories.pendingSetupScriptWorktreeIDs.remove(worktree.id) 46 36 } ··· 49 39 } 50 40 51 41 @Test(.dependencies) func newTerminalWithoutSetupScriptDoesNotConsume() async { 52 - let worktree = Worktree( 53 - id: "/tmp/repo/wt-1", 54 - name: "wt-1", 55 - detail: "detail", 56 - workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt-1"), 57 - repositoryRootURL: URL(fileURLWithPath: "/tmp/repo") 42 + let worktree = makeWorktree() 43 + let repositoriesState = makeRepositoriesState( 44 + worktree: worktree, 45 + pendingSetupScript: false, 46 + selected: true 58 47 ) 59 - let repository = Repository( 60 - id: "/tmp/repo", 61 - rootURL: URL(fileURLWithPath: "/tmp/repo"), 62 - name: "repo", 63 - worktrees: [worktree] 64 - ) 65 - var repositoriesState = RepositoriesFeature.State() 66 - repositoriesState.repositories = [repository] 67 - repositoriesState.selectedWorktreeID = worktree.id 68 48 let sent = LockIsolated<[TerminalClient.Command]>([]) 69 49 let store = TestStore( 70 50 initialState: AppFeature.State( ··· 82 62 await store.send(.newTerminal) 83 63 await store.finish() 84 64 #expect(sent.value == [.createTab(worktree, runSetupScriptIfNew: false)]) 65 + } 66 + 67 + @Test(.dependencies) func tabCreatedDoesNotConsumeSetupScript() async { 68 + let worktree = makeWorktree() 69 + let repositoriesState = makeRepositoriesState( 70 + worktree: worktree, 71 + pendingSetupScript: true, 72 + selected: true 73 + ) 74 + let store = TestStore( 75 + initialState: AppFeature.State( 76 + repositories: repositoriesState, 77 + settings: SettingsFeature.State() 78 + ) 79 + ) { 80 + AppFeature() 81 + } 82 + 83 + await store.send(.terminalEvent(.tabCreated(worktreeID: worktree.id))) 84 + #expect(store.state.repositories.pendingSetupScriptWorktreeIDs.contains(worktree.id)) 85 + await store.finish() 86 + } 87 + 88 + @Test(.dependencies) func setupScriptConsumedEventClearsPending() async { 89 + let worktree = makeWorktree() 90 + let repositoriesState = makeRepositoriesState( 91 + worktree: worktree, 92 + pendingSetupScript: true, 93 + selected: true 94 + ) 95 + let store = TestStore( 96 + initialState: AppFeature.State( 97 + repositories: repositoriesState, 98 + settings: SettingsFeature.State() 99 + ) 100 + ) { 101 + AppFeature() 102 + } 103 + 104 + await store.send(.terminalEvent(.setupScriptConsumed(worktreeID: worktree.id))) 105 + await store.receive(\.repositories.consumeSetupScript) { 106 + $0.repositories.pendingSetupScriptWorktreeIDs.remove(worktree.id) 107 + } 108 + await store.finish() 109 + } 110 + 111 + @Test(.dependencies) func worktreeCreatedTriggersEnsureInitialTabWithSetupScriptFlag() async { 112 + let worktree = makeWorktree() 113 + let repositoriesState = makeRepositoriesState( 114 + worktree: worktree, 115 + pendingSetupScript: true, 116 + selected: false 117 + ) 118 + let sent = LockIsolated<[TerminalClient.Command]>([]) 119 + let store = TestStore( 120 + initialState: AppFeature.State( 121 + repositories: repositoriesState, 122 + settings: SettingsFeature.State() 123 + ) 124 + ) { 125 + AppFeature() 126 + } withDependencies: { 127 + $0.terminalClient.send = { command in 128 + sent.withValue { $0.append(command) } 129 + } 130 + } 131 + 132 + await store.send(.repositories(.delegate(.worktreeCreated(worktree)))) 133 + await store.finish() 134 + #expect( 135 + sent.value == [ 136 + .ensureInitialTab(worktree, runSetupScriptIfNew: true, focusing: false) 137 + ] 138 + ) 139 + } 140 + 141 + @Test(.dependencies) func worktreeCreatedSkipsSetupScriptFlagWhenNotPending() async { 142 + let worktree = makeWorktree() 143 + let repositoriesState = makeRepositoriesState( 144 + worktree: worktree, 145 + pendingSetupScript: false, 146 + selected: false 147 + ) 148 + let sent = LockIsolated<[TerminalClient.Command]>([]) 149 + let store = TestStore( 150 + initialState: AppFeature.State( 151 + repositories: repositoriesState, 152 + settings: SettingsFeature.State() 153 + ) 154 + ) { 155 + AppFeature() 156 + } withDependencies: { 157 + $0.terminalClient.send = { command in 158 + sent.withValue { $0.append(command) } 159 + } 160 + } 161 + 162 + await store.send(.repositories(.delegate(.worktreeCreated(worktree)))) 163 + await store.finish() 164 + #expect( 165 + sent.value == [ 166 + .ensureInitialTab(worktree, runSetupScriptIfNew: false, focusing: false) 167 + ] 168 + ) 169 + } 170 + 171 + private func makeWorktree() -> Worktree { 172 + Worktree( 173 + id: "/tmp/repo/wt-1", 174 + name: "wt-1", 175 + detail: "detail", 176 + workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt-1"), 177 + repositoryRootURL: URL(fileURLWithPath: "/tmp/repo") 178 + ) 179 + } 180 + 181 + private func makeRepositoriesState( 182 + worktree: Worktree, 183 + pendingSetupScript: Bool, 184 + selected: Bool 185 + ) -> RepositoriesFeature.State { 186 + let repository = Repository( 187 + id: "/tmp/repo", 188 + rootURL: URL(fileURLWithPath: "/tmp/repo"), 189 + name: "repo", 190 + worktrees: [worktree] 191 + ) 192 + var repositoriesState = RepositoriesFeature.State() 193 + repositoriesState.repositories = [repository] 194 + if selected { 195 + repositoriesState.selectedWorktreeID = worktree.id 196 + } 197 + if pendingSetupScript { 198 + repositoriesState.pendingSetupScriptWorktreeIDs = [worktree.id] 199 + } 200 + return repositoriesState 85 201 } 86 202 }
+1
supacodeTests/RepositoriesFeatureTests.swift
··· 452 452 await store.receive(\.reloadRepositories) 453 453 await store.receive(\.delegate.repositoriesChanged) 454 454 await store.receive(\.delegate.selectedWorktreeChanged) 455 + await store.receive(\.delegate.worktreeCreated) 455 456 await store.receive(\.repositoriesLoaded) { 456 457 $0.isInitialLoadComplete = true 457 458 }
+8 -3
supacodeTests/WorktreeInfoWatcherManagerTests.swift
··· 21 21 let earlyHasFilesChanged = await collector.hasFilesChanged(worktreeID: worktree.id) 22 22 #expect(earlyHasFilesChanged == false) 23 23 24 - try? await Task.sleep(for: .milliseconds(80)) 25 - let laterHasFilesChanged = await collector.hasFilesChanged(worktreeID: worktree.id) 26 - #expect(laterHasFilesChanged == true) 24 + #expect( 25 + await waitForFilesChangedCount( 26 + collector, 27 + worktreeID: worktree.id, 28 + count: 1, 29 + timeout: .seconds(1) 30 + ) 31 + ) 27 32 28 33 manager.handleCommand(.stop) 29 34 await task.value
+88
supacodeTests/WorktreeTerminalManagerTests.swift
··· 1 + import Foundation 2 + import Testing 3 + 4 + @testable import supacode 5 + 6 + @MainActor 7 + struct WorktreeTerminalManagerTests { 8 + @Test func buffersEventsUntilStreamCreated() async { 9 + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 10 + let worktree = makeWorktree() 11 + let state = manager.state(for: worktree) 12 + 13 + state.onSetupScriptConsumed?() 14 + 15 + let stream = manager.eventStream() 16 + let event = await nextEvent(stream) { event in 17 + if case .setupScriptConsumed = event { 18 + return true 19 + } 20 + return false 21 + } 22 + 23 + #expect(event == .setupScriptConsumed(worktreeID: worktree.id)) 24 + } 25 + 26 + @Test func emitsEventsAfterStreamCreated() async { 27 + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 28 + let worktree = makeWorktree() 29 + let state = manager.state(for: worktree) 30 + 31 + let stream = manager.eventStream() 32 + let eventTask = Task { 33 + await nextEvent(stream) { event in 34 + if case .setupScriptConsumed = event { 35 + return true 36 + } 37 + return false 38 + } 39 + } 40 + 41 + state.onSetupScriptConsumed?() 42 + 43 + let event = await eventTask.value 44 + #expect(event == .setupScriptConsumed(worktreeID: worktree.id)) 45 + } 46 + 47 + @Test func notificationIndicatorUsesCurrentCountOnStreamStart() async { 48 + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 49 + let worktree = makeWorktree() 50 + let state = manager.state(for: worktree) 51 + 52 + state.hasUnseenNotification = true 53 + state.onNotificationIndicatorChanged?() 54 + state.hasUnseenNotification = false 55 + 56 + let stream = manager.eventStream() 57 + var iterator = stream.makeAsyncIterator() 58 + 59 + let first = await iterator.next() 60 + state.onSetupScriptConsumed?() 61 + let second = await iterator.next() 62 + 63 + #expect(first == .notificationIndicatorChanged(count: 0)) 64 + #expect(second == .setupScriptConsumed(worktreeID: worktree.id)) 65 + } 66 + 67 + private func makeWorktree() -> Worktree { 68 + Worktree( 69 + id: "/tmp/repo/wt-1", 70 + name: "wt-1", 71 + detail: "detail", 72 + workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt-1"), 73 + repositoryRootURL: URL(fileURLWithPath: "/tmp/repo") 74 + ) 75 + } 76 + 77 + private func nextEvent( 78 + _ stream: AsyncStream<TerminalClient.Event>, 79 + matching predicate: (TerminalClient.Event) -> Bool 80 + ) async -> TerminalClient.Event? { 81 + for await event in stream { 82 + if predicate(event) { 83 + return event 84 + } 85 + } 86 + return nil 87 + } 88 + }