diff --git a/supacode/Clients/WorktreeInfoWatcher/WorktreeInfoWatcherClient.swift b/supacode/Clients/WorktreeInfoWatcher/WorktreeInfoWatcherClient.swift index 6a8e11bd..3d608a15 100644 --- a/supacode/Clients/WorktreeInfoWatcher/WorktreeInfoWatcherClient.swift +++ b/supacode/Clients/WorktreeInfoWatcher/WorktreeInfoWatcherClient.swift @@ -7,7 +7,9 @@ struct WorktreeInfoWatcherClient { enum Command: Equatable { case setWorktrees([Worktree]) + case setOpenedWorktreeIDs(Set) case setSelectedWorktreeID(Worktree.ID?) + case refreshLineChanges case setPullRequestTrackingEnabled(Bool) case stop } diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 36b7b72f..be77efd5 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -34,6 +34,13 @@ private func makeTerminalRestorableWorktrees(from repositories: [Repository]) -> return worktrees } +private func openedWorktreeIDsForInfoWatcher( + from repositories: RepositoriesFeature.State +) -> Set { + let watchedIDs = Set(repositories.worktreesForInfoWatcher().map(\.id)) + return repositories.openedWorktreeIDs.intersection(watchedIDs) +} + @Reducer struct AppFeature { @ObservableState @@ -320,6 +327,9 @@ struct AppFeature { case .active: return .merge( .send(.repositories(.refreshWorktrees)), + .run { _ in + await worktreeInfoWatcher.send(.refreshLineChanges) + }, .run { send in while !Task.isCancelled { try? await ContinuousClock().sleep(for: .seconds(30)) @@ -444,6 +454,7 @@ struct AppFeature { customCommands: state.selectedCustomCommands ) let worktrees = state.repositories.worktreesForInfoWatcher() + let openedWorktreeIDs = openedWorktreeIDsForInfoWatcher(from: state.repositories) let shouldRestoreLayout = state.launchRestoreMode == .restoreLayout && state.repositories.snapshotPersistencePhase == .active @@ -472,6 +483,9 @@ struct AppFeature { .run { _ in await worktreeInfoWatcher.send(.setWorktrees(worktrees)) }, + .run { _ in + await worktreeInfoWatcher.send(.setOpenedWorktreeIDs(openedWorktreeIDs)) + }, ] if shouldRestoreLayout { effects.append( @@ -491,6 +505,9 @@ struct AppFeature { .run { _ in await worktreeInfoWatcher.send(.setWorktrees(worktrees)) }, + .run { _ in + await worktreeInfoWatcher.send(.setOpenedWorktreeIDs(openedWorktreeIDs)) + }, ] if shouldRestoreLayout { effects.append( @@ -1371,13 +1388,31 @@ struct AppFeature { // marks its worktree as Shelf-visible. Layout restore in // particular only calls `selectWorktree` for the one active // worktree; other restored worktrees only surface here. - return .send(.repositories(.markWorktreeOpened(worktreeID))) + var openedWorktreeIDs = openedWorktreeIDsForInfoWatcher(from: state.repositories) + if state.repositories.worktree(for: worktreeID) != nil { + openedWorktreeIDs.insert(worktreeID) + } + let syncedOpenedWorktreeIDs = openedWorktreeIDs + return .merge( + .send(.repositories(.markWorktreeOpened(worktreeID))), + .run { _ in + await worktreeInfoWatcher.send(.setOpenedWorktreeIDs(syncedOpenedWorktreeIDs)) + } + ) case .terminalEvent(.tabClosed(let worktreeID, let remainingTabs)): // Closing the last tab retires the book from the Shelf. Other // closes are routine and need no Reducer-side bookkeeping. guard remainingTabs == 0 else { return .none } - return .send(.repositories(.markWorktreeClosed(worktreeID))) + var openedWorktreeIDs = openedWorktreeIDsForInfoWatcher(from: state.repositories) + openedWorktreeIDs.remove(worktreeID) + let syncedOpenedWorktreeIDs = openedWorktreeIDs + return .merge( + .send(.repositories(.markWorktreeClosed(worktreeID))), + .run { _ in + await worktreeInfoWatcher.send(.setOpenedWorktreeIDs(syncedOpenedWorktreeIDs)) + } + ) case .terminalEvent(.focusChanged(_, let surfaceID)): // Keep the Active Agents panel's keyboard-navigation anchor in sync with diff --git a/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift b/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift index 58ff8718..79d9cc03 100644 --- a/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift +++ b/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift @@ -1,11 +1,22 @@ +import CoreServices import Darwin import Dispatch import Foundation +@MainActor +protocol WorktreeFileEventMonitoring: AnyObject { + func cancel() +} + @MainActor final class WorktreeInfoWatcherManager { typealias WorktreePhaseOffset = @Sendable (Worktree.ID, Duration) -> Duration typealias RepositoryPhaseOffset = @Sendable (URL, Duration) -> Duration + typealias WorktreeFileEventMonitorFactory = + @MainActor @Sendable ( + _ worktree: Worktree, + _ onEvent: @escaping @MainActor @Sendable () -> Void + ) -> WorktreeFileEventMonitoring? private struct HeadWatcher { let headURL: URL @@ -28,7 +39,7 @@ final class WorktreeInfoWatcherManager { let initialDelay: Duration let immediate: Bool let forceReschedule: Bool - let makeEvent: (Worktree.ID) -> WorktreeInfoWatcherClient.Event + let makeEvent: (Worktree.ID) -> WorktreeInfoWatcherClient.Event? } private struct RefreshTiming: Equatable { @@ -37,19 +48,25 @@ final class WorktreeInfoWatcherManager { } private let filesChangedDebounceInterval: Duration + private let lineChangesEventDebounceInterval: Duration + private let lineChangesSafetyRefreshInterval: Duration private let pullRequestSelectionRefreshCooldown: Duration private let refreshTiming: RefreshTiming private let lineChangePhaseOffset: WorktreePhaseOffset private let pullRequestPhaseOffset: RepositoryPhaseOffset + private let worktreeFileEventMonitorFactory: WorktreeFileEventMonitorFactory private let sleep: @Sendable (Duration) async throws -> Void private var worktrees: [Worktree.ID: Worktree] = [:] private var headWatchers: [Worktree.ID: HeadWatcher] = [:] + private var worktreeFileEventMonitors: [Worktree.ID: WorktreeFileEventMonitoring] = [:] private var branchDebounceTasks: [Worktree.ID: Task] = [:] private var filesDebounceTasks: [Worktree.ID: Task] = [:] private var restartTasks: [Worktree.ID: Task] = [:] private var pullRequestTasks: [URL: RefreshTask] = [:] - private var lineChangeTasks: [Worktree.ID: RefreshTask] = [:] + private var lineChangeSafetyTasks: [Worktree.ID: RefreshTask] = [:] + private var lineChangeRefreshTasks: [Worktree.ID: Task] = [:] private var deferredLineChangeIDs: Set = [] + private var openedWorktreeIDs: Set = [] private var hasCompletedInitialWorktreeLoad = false private var selectedWorktreeID: Worktree.ID? private var pullRequestTrackingEnabled = true @@ -60,16 +77,23 @@ final class WorktreeInfoWatcherManager { focusedInterval: Duration = .seconds(30), unfocusedInterval: Duration = .seconds(60), filesChangedDebounceInterval: Duration = .seconds(5), + lineChangesEventDebounceInterval: Duration = .seconds(30), + lineChangesSafetyRefreshInterval: Duration = .seconds(300), pullRequestSelectionRefreshCooldown: Duration = .seconds(5), lineChangePhaseOffset: @escaping WorktreePhaseOffset = WorktreeInfoWatcherManager.defaultLineChangePhaseOffset, pullRequestPhaseOffset: @escaping RepositoryPhaseOffset = WorktreeInfoWatcherManager.defaultPullRequestPhaseOffset, + worktreeFileEventMonitorFactory: @escaping WorktreeFileEventMonitorFactory = + WorktreeInfoWatcherManager.defaultWorktreeFileEventMonitorFactory, clock: C = ContinuousClock() ) { refreshTiming = RefreshTiming(focused: focusedInterval, unfocused: unfocusedInterval) self.filesChangedDebounceInterval = filesChangedDebounceInterval + self.lineChangesEventDebounceInterval = lineChangesEventDebounceInterval + self.lineChangesSafetyRefreshInterval = lineChangesSafetyRefreshInterval self.pullRequestSelectionRefreshCooldown = pullRequestSelectionRefreshCooldown self.lineChangePhaseOffset = lineChangePhaseOffset self.pullRequestPhaseOffset = pullRequestPhaseOffset + self.worktreeFileEventMonitorFactory = worktreeFileEventMonitorFactory self.sleep = { duration in try await clock.sleep(for: duration) } @@ -79,8 +103,12 @@ final class WorktreeInfoWatcherManager { switch command { case .setWorktrees(let worktrees): setWorktrees(worktrees) + case .setOpenedWorktreeIDs(let worktreeIDs): + setOpenedWorktreeIDs(worktreeIDs) case .setSelectedWorktreeID(let worktreeID): setSelectedWorktreeID(worktreeID) + case .refreshLineChanges: + scheduleLineChangesRefreshForAllWorktrees() case .setPullRequestTrackingEnabled(let isEnabled): setPullRequestTrackingEnabled(isEnabled) case .stop: @@ -106,6 +134,7 @@ final class WorktreeInfoWatcherManager { } if !removedIDs.isEmpty { deferredLineChangeIDs.subtract(removedIDs) + openedWorktreeIDs.subtract(removedIDs) } let newIDs = desiredIDs.subtracting(currentIDs) if !newIDs.isEmpty && !isInitialWorktreeLoad { @@ -114,10 +143,12 @@ final class WorktreeInfoWatcherManager { self.worktrees = worktreesByID for worktree in worktrees { configureWatcher(for: worktree) - updateLineChangeSchedule( - worktreeID: worktree.id, - immediate: isInitialWorktreeLoad || !deferredLineChangeIDs.contains(worktree.id) - ) + if isInitialWorktreeLoad || !deferredLineChangeIDs.contains(worktree.id) { + emitLineChangesChanged(worktreeID: worktree.id) + } else if newIDs.contains(worktree.id) { + scheduleLineChangesRefresh(worktreeID: worktree.id, delay: deferredLineChangesRefreshDelay(for: worktree)) + } + syncLineChangesActivity(for: worktree.id) } if isInitialWorktreeLoad { hasCompletedInitialWorktreeLoad = true @@ -138,6 +169,18 @@ final class WorktreeInfoWatcherManager { } } + private func setOpenedWorktreeIDs(_ worktreeIDs: Set) { + let validIDs = worktreeIDs.intersection(worktrees.keys) + guard validIDs != openedWorktreeIDs else { + return + } + let affectedIDs = openedWorktreeIDs.symmetricDifference(validIDs) + openedWorktreeIDs = validIDs + for worktreeID in affectedIDs { + syncLineChangesActivity(for: worktreeID) + } + } + private func setSelectedWorktreeID(_ worktreeID: Worktree.ID?) { guard selectedWorktreeID != worktreeID else { return @@ -147,10 +190,11 @@ final class WorktreeInfoWatcherManager { selectedWorktreeID = worktreeID let nextRepository = worktreeID.flatMap { worktrees[$0]?.repositoryRootURL } if let previousWorktreeID { - updateLineChangeSchedule(worktreeID: previousWorktreeID, immediate: false) + syncLineChangesActivity(for: previousWorktreeID) } if let worktreeID { - updateLineChangeSchedule(worktreeID: worktreeID, immediate: true) + emitLineChangesChanged(worktreeID: worktreeID) + syncLineChangesActivity(for: worktreeID) } if let previousRepository, previousRepository == nextRepository { updatePullRequestSchedule( @@ -248,13 +292,6 @@ final class WorktreeInfoWatcherManager { await MainActor.run { guard let self else { return } self.emit(.filesChanged(worktreeID: worktreeID)) - if !self.deferredLineChangeIDs.contains(worktreeID) { - self.updateLineChangeSchedule( - worktreeID: worktreeID, - immediate: false, - forceReschedule: true - ) - } } } filesDebounceTasks[worktreeID] = task @@ -291,10 +328,12 @@ final class WorktreeInfoWatcherManager { private func stopWatcher(for worktreeID: Worktree.ID) { stopHeadWatcher(for: worktreeID) + stopWorktreeFileEventMonitor(for: worktreeID) branchDebounceTasks.removeValue(forKey: worktreeID)?.cancel() filesDebounceTasks.removeValue(forKey: worktreeID)?.cancel() restartTasks.removeValue(forKey: worktreeID)?.cancel() - lineChangeTasks.removeValue(forKey: worktreeID)?.task.cancel() + lineChangeSafetyTasks.removeValue(forKey: worktreeID)?.task.cancel() + lineChangeRefreshTasks.removeValue(forKey: worktreeID)?.cancel() } private func stopAll() { @@ -313,16 +352,25 @@ final class WorktreeInfoWatcherManager { for task in pullRequestTasks.values { task.task.cancel() } - for task in lineChangeTasks.values { + for task in lineChangeSafetyTasks.values { task.task.cancel() } + for task in lineChangeRefreshTasks.values { + task.cancel() + } + for monitor in worktreeFileEventMonitors.values { + monitor.cancel() + } headWatchers.removeAll() + worktreeFileEventMonitors.removeAll() branchDebounceTasks.removeAll() filesDebounceTasks.removeAll() restartTasks.removeAll() pullRequestTasks.removeAll() - lineChangeTasks.removeAll() + lineChangeSafetyTasks.removeAll() + lineChangeRefreshTasks.removeAll() deferredLineChangeIDs.removeAll() + openedWorktreeIDs.removeAll() hasCompletedInitialWorktreeLoad = false cancelAllPullRequestSelectionCooldownTasks() worktrees.removeAll() @@ -410,28 +458,107 @@ final class WorktreeInfoWatcherManager { emit(.repositoryPullRequestRefresh(repositoryRootURL: repositoryRootURL, worktreeIDs: worktreeIDs)) } - private func updateLineChangeSchedule( + private func scheduleLineChangesRefreshForAllWorktrees() { + for worktree in worktrees.values { + scheduleLineChangesRefresh(worktreeID: worktree.id, delay: lineChangesRefreshDelay(for: worktree)) + } + } + + private func scheduleLineChangesRefresh( worktreeID: Worktree.ID, - immediate: Bool, - forceReschedule: Bool = false + delay: Duration ) { guard worktrees[worktreeID] != nil else { return } - let interval = worktreeID == selectedWorktreeID ? refreshTiming.focused : refreshTiming.unfocused - let shouldEmit = immediate && !deferredLineChangeIDs.contains(worktreeID) + lineChangeRefreshTasks[worktreeID]?.cancel() + let sleep = self.sleep + let task = Task { [weak self, sleep] in + do { + try await sleep(delay) + } catch { + return + } + await MainActor.run { + self?.lineChangeRefreshTasks.removeValue(forKey: worktreeID) + self?.emitLineChangesChanged(worktreeID: worktreeID) + } + } + lineChangeRefreshTasks[worktreeID] = task + } + + private func scheduleLineChangesDebouncedRefresh(worktreeID: Worktree.ID) { + scheduleLineChangesRefresh(worktreeID: worktreeID, delay: lineChangesEventDebounceInterval) + } + + private func updateLineChangesSafetySchedule(worktreeID: Worktree.ID) { + guard isLineChangesActive(worktreeID), worktrees[worktreeID] != nil else { + lineChangeSafetyTasks.removeValue(forKey: worktreeID)?.task.cancel() + return + } let request = RepeatingTaskRequest( worktreeID: worktreeID, - interval: interval, - initialDelay: interval + lineChangePhaseOffset(worktreeID, interval), - immediate: shouldEmit, - forceReschedule: forceReschedule, + interval: lineChangesSafetyRefreshInterval, + initialDelay: lineChangesSafetyRefreshInterval, + immediate: false, + forceReschedule: false, makeEvent: { [weak self] worktreeID in - self?.deferredLineChangeIDs.remove(worktreeID) - return .filesChanged(worktreeID: worktreeID) + self?.makeLineChangesChangedEvent(worktreeID: worktreeID) } ) - updateRepeatingTask(request, tasks: &lineChangeTasks) + updateRepeatingTask(request, tasks: &lineChangeSafetyTasks) + } + + private func lineChangesRefreshDelay(for worktree: Worktree) -> Duration { + let interval = worktree.id == selectedWorktreeID ? refreshTiming.focused : refreshTiming.unfocused + return lineChangePhaseOffset(worktree.id, interval) + } + + private func deferredLineChangesRefreshDelay(for worktree: Worktree) -> Duration { + let interval = worktree.id == selectedWorktreeID ? refreshTiming.focused : refreshTiming.unfocused + return interval + lineChangePhaseOffset(worktree.id, interval) + } + + private func emitLineChangesChanged(worktreeID: Worktree.ID) { + guard let event = makeLineChangesChangedEvent(worktreeID: worktreeID) else { + return + } + emit(event) + } + + private func makeLineChangesChangedEvent(worktreeID: Worktree.ID) -> WorktreeInfoWatcherClient.Event? { + guard worktrees[worktreeID] != nil else { + return nil + } + deferredLineChangeIDs.remove(worktreeID) + return .filesChanged(worktreeID: worktreeID) + } + + private func syncLineChangesActivity(for worktreeID: Worktree.ID) { + guard let worktree = worktrees[worktreeID], isLineChangesActive(worktreeID) else { + stopWorktreeFileEventMonitor(for: worktreeID) + lineChangeSafetyTasks.removeValue(forKey: worktreeID)?.task.cancel() + return + } + startWorktreeFileEventMonitorIfNeeded(for: worktree) + updateLineChangesSafetySchedule(worktreeID: worktreeID) + } + + private func isLineChangesActive(_ worktreeID: Worktree.ID) -> Bool { + selectedWorktreeID == worktreeID || openedWorktreeIDs.contains(worktreeID) + } + + private func startWorktreeFileEventMonitorIfNeeded(for worktree: Worktree) { + guard worktreeFileEventMonitors[worktree.id] == nil else { + return + } + worktreeFileEventMonitors[worktree.id] = worktreeFileEventMonitorFactory(worktree) { [weak self] in + self?.scheduleLineChangesDebouncedRefresh(worktreeID: worktree.id) + } + } + + private func stopWorktreeFileEventMonitor(for worktreeID: Worktree.ID) { + worktreeFileEventMonitors.removeValue(forKey: worktreeID)?.cancel() } private func updateRepeatingTask( @@ -441,13 +568,17 @@ final class WorktreeInfoWatcherManager { let worktreeID = request.worktreeID if let existing = tasks[worktreeID], existing.interval == request.interval, !request.forceReschedule { if request.immediate { - emit(request.makeEvent(worktreeID)) + if let event = request.makeEvent(worktreeID) { + emit(event) + } } return } tasks[worktreeID]?.task.cancel() if request.immediate { - emit(request.makeEvent(worktreeID)) + if let event = request.makeEvent(worktreeID) { + emit(event) + } } let sleep = self.sleep let task = Task { [weak self, sleep] in @@ -458,7 +589,10 @@ final class WorktreeInfoWatcherManager { } while !Task.isCancelled { await MainActor.run { - self?.emit(request.makeEvent(worktreeID)) + guard let event = request.makeEvent(worktreeID) else { + return + } + self?.emit(event) } do { try await sleep(request.interval) @@ -484,6 +618,13 @@ final class WorktreeInfoWatcherManager { stablePhaseOffset(seed: repositoryRootURL.path(percentEncoded: false), interval: interval) } + private static func defaultWorktreeFileEventMonitorFactory( + worktree: Worktree, + onEvent: @escaping @MainActor @Sendable () -> Void + ) -> WorktreeFileEventMonitoring? { + FSEventsWorktreeFileEventMonitor(rootURL: worktree.workingDirectory, onEvent: onEvent) + } + nonisolated private static func stablePhaseOffset(seed: String, interval: Duration) -> Duration { let intervalMilliseconds = durationMilliseconds(interval) guard intervalMilliseconds > 0 else { @@ -559,3 +700,70 @@ final class WorktreeInfoWatcherManager { return true } } + +private final class FSEventsWorktreeFileEventMonitor: WorktreeFileEventMonitoring { + private let onEvent: @MainActor @Sendable () -> Void + private var stream: FSEventStreamRef? + + init?( + rootURL: URL, + onEvent: @escaping @MainActor @Sendable () -> Void + ) { + self.onEvent = onEvent + let path = rootURL.path(percentEncoded: false) + var context = FSEventStreamContext( + version: 0, + info: nil, + retain: nil, + release: nil, + copyDescription: nil + ) + context.info = Unmanaged.passUnretained(self).toOpaque() + let callback: FSEventStreamCallback = { _, callbackInfo, _, _, _, _ in + guard let callbackInfo else { return } + let monitor = Unmanaged + .fromOpaque(callbackInfo) + .takeUnretainedValue() + Task { @MainActor in + monitor.onEvent() + } + } + stream = FSEventStreamCreate( + nil, + callback, + &context, + [path] as CFArray, + FSEventStreamEventId(kFSEventStreamEventIdSinceNow), + 1.0, + FSEventStreamCreateFlags( + kFSEventStreamCreateFlagFileEvents + | kFSEventStreamCreateFlagNoDefer + | kFSEventStreamCreateFlagWatchRoot + ) + ) + guard let stream else { + return nil + } + FSEventStreamScheduleWithRunLoop( + stream, + CFRunLoopGetMain(), + CFRunLoopMode.defaultMode.rawValue + ) + guard FSEventStreamStart(stream) else { + FSEventStreamInvalidate(stream) + FSEventStreamRelease(stream) + self.stream = nil + return nil + } + } + + func cancel() { + guard let stream else { + return + } + FSEventStreamStop(stream) + FSEventStreamInvalidate(stream) + FSEventStreamRelease(stream) + self.stream = nil + } +} diff --git a/supacodeTests/AppFeatureTerminalSetupScriptTests.swift b/supacodeTests/AppFeatureTerminalSetupScriptTests.swift index d0ae2290..4f259215 100644 --- a/supacodeTests/AppFeatureTerminalSetupScriptTests.swift +++ b/supacodeTests/AppFeatureTerminalSetupScriptTests.swift @@ -71,6 +71,7 @@ struct AppFeatureTerminalSetupScriptTests { pendingSetupScript: true, selected: true ) + let watcherCommands = LockIsolated<[WorktreeInfoWatcherClient.Command]>([]) let store = TestStore( initialState: AppFeature.State( repositories: repositoriesState, @@ -78,6 +79,10 @@ struct AppFeatureTerminalSetupScriptTests { ) ) { AppFeature() + } withDependencies: { + $0.worktreeInfoWatcher.send = { command in + watcherCommands.withValue { $0.append(command) } + } } await store.send(.terminalEvent(.tabCreated(worktreeID: worktree.id))) @@ -86,6 +91,37 @@ struct AppFeatureTerminalSetupScriptTests { } #expect(store.state.repositories.pendingSetupScriptWorktreeIDs.contains(worktree.id)) await store.finish() + #expect(watcherCommands.value == [.setOpenedWorktreeIDs([worktree.id])]) + } + + @Test(.dependencies) func tabClosedSyncsOpenedWorktreesToInfoWatcher() async { + let worktree = makeWorktree() + var repositoriesState = makeRepositoriesState( + worktree: worktree, + pendingSetupScript: false, + selected: true + ) + repositoriesState.openedWorktreeIDs = [worktree.id] + let watcherCommands = LockIsolated<[WorktreeInfoWatcherClient.Command]>([]) + let store = TestStore( + initialState: AppFeature.State( + repositories: repositoriesState, + settings: SettingsFeature.State() + ) + ) { + AppFeature() + } withDependencies: { + $0.worktreeInfoWatcher.send = { command in + watcherCommands.withValue { $0.append(command) } + } + } + + await store.send(.terminalEvent(.tabClosed(worktreeID: worktree.id, remainingTabs: 0))) + await store.receive(\.repositories.markWorktreeClosed) { + $0.repositories.openedWorktreeIDs = [] + } + await store.finish() + #expect(watcherCommands.value == [.setOpenedWorktreeIDs([])]) } @Test(.dependencies) func setupScriptConsumedEventClearsPending() async { diff --git a/supacodeTests/WorktreeInfoWatcherManagerTests.swift b/supacodeTests/WorktreeInfoWatcherManagerTests.swift index 6cdc309c..962b686b 100644 --- a/supacodeTests/WorktreeInfoWatcherManagerTests.swift +++ b/supacodeTests/WorktreeInfoWatcherManagerTests.swift @@ -25,7 +25,7 @@ struct WorktreeInfoWatcherManagerTests { try FileManager.default.removeItem(at: tempWorktree.tempRoot) } - @Test func defersLineChangesForWorktreesAddedAfterInitialLoad() async throws { + @Test func worktreesAddedAfterInitialLoadRefreshLineChangesOnce() async throws { let clock = TestClock() let tempRepository = try makeTempRepository(worktreeNames: ["sparrow", "swift"]) let firstWorktree = try #require(tempRepository.worktrees.first) @@ -56,6 +56,10 @@ struct WorktreeInfoWatcherManagerTests { await drainAsyncEvents(120) #expect(await collector.filesChangedCount(worktreeID: secondWorktree.id) == 1) + await clock.advance(by: .milliseconds(80)) + await drainAsyncEvents(120) + #expect(await collector.filesChangedCount(worktreeID: secondWorktree.id) == 1) + manager.handleCommand(.stop) await task.value try FileManager.default.removeItem(at: tempRepository.tempRoot) @@ -113,6 +117,91 @@ struct WorktreeInfoWatcherManagerTests { await drainAsyncEvents(120) #expect(await collector.filesChangedCount(worktreeID: thirdWorktree.id) == 1) + await clock.advance(by: .milliseconds(120)) + await drainAsyncEvents(120) + #expect(await collector.filesChangedCount(worktreeID: secondWorktree.id) == 1) + #expect(await collector.filesChangedCount(worktreeID: thirdWorktree.id) == 1) + + manager.handleCommand(.stop) + await task.value + try FileManager.default.removeItem(at: tempRepository.tempRoot) + } + + @Test func activeWorktreeRefreshesLineChangesAfterFileEventDebounce() async throws { + let clock = TestClock() + let tempRepository = try makeTempRepository(worktreeNames: ["sparrow", "swift"]) + let activeWorktree = try #require(tempRepository.worktrees.first) + let inactiveWorktree = try #require(tempRepository.worktrees.dropFirst().first) + let monitorStore = TestWorktreeFileEventMonitorStore() + let manager = WorktreeInfoWatcherManager( + focusedInterval: .seconds(3_600), + unfocusedInterval: .seconds(3_600), + lineChangesEventDebounceInterval: .milliseconds(80), + lineChangesSafetyRefreshInterval: .seconds(3_600), + lineChangePhaseOffset: { _, _ in .zero }, + pullRequestPhaseOffset: { _, _ in .zero }, + worktreeFileEventMonitorFactory: monitorStore.makeMonitor, + clock: clock + ) + let (collector, task) = startCollecting(manager.eventStream()) + + manager.handleCommand(.setPullRequestTrackingEnabled(false)) + manager.handleCommand(.setWorktrees([activeWorktree, inactiveWorktree])) + await drainAsyncEvents(120) + #expect(await collector.filesChangedCount(worktreeID: activeWorktree.id) == 1) + #expect(await collector.filesChangedCount(worktreeID: inactiveWorktree.id) == 1) + #expect(monitorStore.monitor(for: activeWorktree.id) == nil) + #expect(monitorStore.monitor(for: inactiveWorktree.id) == nil) + + manager.handleCommand(.setOpenedWorktreeIDs([activeWorktree.id])) + let monitor = try #require(monitorStore.monitor(for: activeWorktree.id)) + #expect(monitorStore.monitor(for: inactiveWorktree.id) == nil) + + monitor.emit() + await clock.advance(by: .milliseconds(79)) + await drainAsyncEvents(120) + #expect(await collector.filesChangedCount(worktreeID: activeWorktree.id) == 1) + + await clock.advance(by: .milliseconds(1)) + await drainAsyncEvents(120) + #expect(await collector.filesChangedCount(worktreeID: activeWorktree.id) == 2) + #expect(await collector.filesChangedCount(worktreeID: inactiveWorktree.id) == 1) + + manager.handleCommand(.stop) + await task.value + try FileManager.default.removeItem(at: tempRepository.tempRoot) + } + + @Test func activeWorktreeUsesSlowSafetyLineChangesRefresh() async throws { + let clock = TestClock() + let tempRepository = try makeTempRepository(worktreeNames: ["sparrow"]) + let worktree = try #require(tempRepository.worktrees.first) + let monitorStore = TestWorktreeFileEventMonitorStore() + let manager = WorktreeInfoWatcherManager( + focusedInterval: .seconds(3_600), + unfocusedInterval: .seconds(3_600), + lineChangesSafetyRefreshInterval: .milliseconds(80), + lineChangePhaseOffset: { _, _ in .zero }, + pullRequestPhaseOffset: { _, _ in .zero }, + worktreeFileEventMonitorFactory: monitorStore.makeMonitor, + clock: clock + ) + let (collector, task) = startCollecting(manager.eventStream()) + + manager.handleCommand(.setPullRequestTrackingEnabled(false)) + manager.handleCommand(.setWorktrees([worktree])) + manager.handleCommand(.setOpenedWorktreeIDs([worktree.id])) + await drainAsyncEvents(120) + #expect(await collector.filesChangedCount(worktreeID: worktree.id) == 1) + + await clock.advance(by: .milliseconds(79)) + await drainAsyncEvents(120) + #expect(await collector.filesChangedCount(worktreeID: worktree.id) == 1) + + await clock.advance(by: .milliseconds(1)) + await drainAsyncEvents(120) + #expect(await collector.filesChangedCount(worktreeID: worktree.id) == 2) + manager.handleCommand(.stop) await task.value try FileManager.default.removeItem(at: tempRepository.tempRoot) @@ -355,3 +444,39 @@ private func drainAsyncEvents(_ iterations: Int = 20) async { await Task.yield() } } + +@MainActor +private final class TestWorktreeFileEventMonitorStore { + private var monitors: [Worktree.ID: TestWorktreeFileEventMonitor] = [:] + + func makeMonitor( + worktree: Worktree, + onEvent: @escaping @MainActor @Sendable () -> Void + ) -> WorktreeFileEventMonitoring? { + let monitor = TestWorktreeFileEventMonitor(onEvent: onEvent) + monitors[worktree.id] = monitor + return monitor + } + + func monitor(for worktreeID: Worktree.ID) -> TestWorktreeFileEventMonitor? { + monitors[worktreeID] + } +} + +@MainActor +private final class TestWorktreeFileEventMonitor: WorktreeFileEventMonitoring { + private let onEvent: @MainActor @Sendable () -> Void + private(set) var isCanceled = false + + init(onEvent: @escaping @MainActor @Sendable () -> Void) { + self.onEvent = onEvent + } + + func emit() { + onEvent() + } + + func cancel() { + isCanceled = true + } +}