diff --git a/supacode/Clients/WorktreeInfoWatcher/WorktreeInfoWatcherClient.swift b/supacode/Clients/WorktreeInfoWatcher/WorktreeInfoWatcherClient.swift index 6a8e11bda..afb833923 100644 --- a/supacode/Clients/WorktreeInfoWatcher/WorktreeInfoWatcherClient.swift +++ b/supacode/Clients/WorktreeInfoWatcher/WorktreeInfoWatcherClient.swift @@ -9,6 +9,7 @@ struct WorktreeInfoWatcherClient { case setWorktrees([Worktree]) case setSelectedWorktreeID(Worktree.ID?) case setPullRequestTrackingEnabled(Bool) + case refresh case stop } diff --git a/supacode/Commands/WorktreeCommands.swift b/supacode/Commands/WorktreeCommands.swift index 0d8bb8f0f..000544507 100644 --- a/supacode/Commands/WorktreeCommands.swift +++ b/supacode/Commands/WorktreeCommands.swift @@ -88,7 +88,7 @@ private struct WorktreeMainMenu: Commands { .disabled(snapshot.selectedPullRequestURL == nil || !snapshot.githubIntegrationEnabled) Divider() Button("Refresh Worktrees", systemImage: "arrow.clockwise") { - store.send(.repositories(.refreshWorktrees)) + store.send(.refreshWorktreesRequested) } .appKeyboardShortcut(refresh) .help("Refresh (\(refresh?.display ?? "none"))") diff --git a/supacode/Features/App/Models/WorktreeMenuSnapshot.swift b/supacode/Features/App/Models/WorktreeMenuSnapshot.swift index 415de78c8..fd14fbed8 100644 --- a/supacode/Features/App/Models/WorktreeMenuSnapshot.swift +++ b/supacode/Features/App/Models/WorktreeMenuSnapshot.swift @@ -120,6 +120,7 @@ extension AppFeature.Action { // directly; any downstream mutation flows back through a classified arm. case .applicationDidBecomeActive, .applicationDidResignActive, .appLaunched, .scenePhaseChanged, .openActionSelectionChanged, + .refreshWorktreesRequested, .worktreeSettingsLoaded, .openSelectedWorktree, .revealInFinder, .openWorktree, .openWorktreeFailed, .requestQuit, .requestTerminateAllTerminalSessions, .newTerminal, diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index a9d4339e4..eb66355fe 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -193,6 +193,7 @@ struct AppFeature { case appLaunched case scenePhaseChanged(ScenePhase) case repositories(RepositoriesFeature.Action) + case refreshWorktreesRequested case settings(SettingsFeature.Action) case updates(UpdatesFeature.Action) case commandPalette(CommandPaletteFeature.Action) @@ -314,6 +315,14 @@ struct AppFeature { case .agentPresence: return .none + case .refreshWorktreesRequested: + return .merge( + .send(.repositories(.refreshWorktrees)), + .run { _ in + await worktreeInfoWatcher.send(.refresh) + } + ) + case .scenePhaseChanged(let phase): switch phase { case .active: @@ -1189,7 +1198,7 @@ struct AppFeature { return .send(.repositories(.selectArchivedWorktrees)) case .commandPalette(.delegate(.refreshWorktrees)): - return .send(.repositories(.refreshWorktrees)) + return .send(.refreshWorktreesRequested) case .commandPalette(.delegate(.ghosttyCommand(let action))): guard let worktree = state.repositories.worktree(for: state.repositories.selectedWorktreeID) else { diff --git a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift index 566a208d0..f3f342382 100644 --- a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift +++ b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift @@ -392,7 +392,7 @@ struct CommandPaletteFeature { let ordered = idleRows.enumerated().sorted { lhs, rhs in switch (mruRank[lhs.element.id], mruRank[rhs.element.id]) { - case let (lhsRank?, rhsRank?): return lhsRank < rhsRank + case (let lhsRank?, let rhsRank?): return lhsRank < rhsRank case (_?, nil): return true case (nil, _?): return false case (nil, nil): return lhs.offset < rhs.offset diff --git a/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift b/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift index 9b6bbc188..6087367be 100644 --- a/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift +++ b/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift @@ -1,3 +1,4 @@ +import CoreServices import Darwin import Dispatch import Foundation @@ -5,6 +6,79 @@ import SupacodeSettingsShared private let watcherLogger = SupaLogger("WorktreeInfoWatcher") +private final class WorktreeFileEventMonitor { + let rootURL: URL + private let onEvent: @MainActor @Sendable () -> Void + private nonisolated(unsafe) var stream: FSEventStreamRef? + + init?( + rootURL: URL, + onEvent: @escaping @MainActor @Sendable () -> Void + ) { + self.rootURL = rootURL + 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 + } + FSEventStreamSetDispatchQueue(stream, DispatchQueue.main) + guard FSEventStreamStart(stream) else { + FSEventStreamInvalidate(stream) + FSEventStreamRelease(stream) + self.stream = nil + return nil + } + } + + deinit { + Self.release(&stream) + } + + func cancel() { + Self.release(&stream) + } + + private nonisolated static func release(_ stream: inout FSEventStreamRef?) { + guard let streamRef = stream else { + return + } + FSEventStreamStop(streamRef) + FSEventStreamInvalidate(streamRef) + FSEventStreamRelease(streamRef) + stream = nil + } +} + @MainActor final class WorktreeInfoWatcherManager { /// Hard cap on the live event buffer. These events are refresh signals (not @@ -28,14 +102,6 @@ final class WorktreeInfoWatcherManager { let task: Task } - private struct RepeatingTaskRequest { - let worktreeID: Worktree.ID - let interval: Duration - let immediate: Bool - let forceReschedule: Bool - let makeEvent: (Worktree.ID) -> WorktreeInfoWatcherClient.Event - } - private struct RefreshTiming: Equatable { let focused: Duration let unfocused: Duration @@ -51,16 +117,16 @@ final class WorktreeInfoWatcherManager { private let pollRemoteBranch: @Sendable (Worktree) async -> String? private var worktrees: [Worktree.ID: Worktree] = [:] private var headWatchers: [Worktree.ID: HeadWatcher] = [:] + private var fileEventMonitors: [Worktree.ID: WorktreeFileEventMonitor] = [:] /// Remote worktrees can't kqueue their `.git/HEAD` (it lives on another /// host), so they poll `git rev-parse` over SSH on the same focused / - /// unfocused cadence as line-changes / PR refresh. + /// unfocused cadence. private var remoteHeadPollTasks: [Worktree.ID: RefreshTask] = [:] private var lastKnownRemoteBranch: [Worktree.ID: String] = [:] 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 lineChangeRefreshTasks: [Worktree.ID: Task] = [:] private var deferredLineChangeIDs: Set = [] private var hasCompletedInitialWorktreeLoad = false private var selectedWorktreeID: Worktree.ID? @@ -96,6 +162,8 @@ final class WorktreeInfoWatcherManager { setSelectedWorktreeID(worktreeID) case .setPullRequestTrackingEnabled(let isEnabled): setPullRequestTrackingEnabled(isEnabled) + case .refresh: + refreshAll() case .stop: stopAll() } @@ -113,6 +181,7 @@ final class WorktreeInfoWatcherManager { private func setWorktrees(_ worktrees: [Worktree]) { let isInitialWorktreeLoad = !hasCompletedInitialWorktreeLoad && self.worktrees.isEmpty && !worktrees.isEmpty + let previousWorktrees = self.worktrees // Keep the first entry on a duplicate WorktreeID instead of trapping; a repo registered // under both its working dir and `.bare/` enumerates the same worktree twice. let worktreesByID = Dictionary(worktrees.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) @@ -133,23 +202,26 @@ final class WorktreeInfoWatcherManager { // Iterate the de-duplicated values so a duplicate WorktreeID doesn't configure // the same watcher or emit its immediate refresh twice. var repositoryRoots: Set = [] + var repositoryRootsToRefresh = Set(removedIDs.compactMap { previousWorktrees[$0]?.repositoryRootURL }) for worktree in worktreesByID.values { configureWatcher(for: worktree) - updateLineChangeSchedule( - worktreeID: worktree.id, - immediate: isInitialWorktreeLoad || !deferredLineChangeIDs.contains(worktree.id) - ) + let didWorktreeChange = previousWorktrees[worktree.id] != worktree + if isInitialWorktreeLoad || newIDs.contains(worktree.id) || didWorktreeChange { + repositoryRootsToRefresh.insert(worktree.repositoryRootURL) + let isDeferred = deferredLineChangeIDs.contains(worktree.id) + if isDeferred { + scheduleLineChangeRefresh(worktreeID: worktree.id, delay: refreshInterval(for: worktree.id)) + } else { + emitLineChangesChanged(worktreeID: worktree.id) + } + } repositoryRoots.insert(worktree.repositoryRootURL) } if isInitialWorktreeLoad { hasCompletedInitialWorktreeLoad = true } - for repositoryRootURL in repositoryRoots { - updatePullRequestSchedule(repositoryRootURL: repositoryRootURL, immediate: true) - } - let obsoleteRepositories = pullRequestTasks.keys.filter { !repositoryRoots.contains($0) } - for repositoryRootURL in obsoleteRepositories { - pullRequestTasks.removeValue(forKey: repositoryRootURL)?.task.cancel() + for repositoryRootURL in repositoryRootsToRefresh { + refreshPullRequests(repositoryRootURL: repositoryRootURL) } let obsoleteCooldownRepositories = pullRequestSelectionCooldownTasksByRepo.keys.filter { !repositoryRoots.contains($0) @@ -168,32 +240,24 @@ final class WorktreeInfoWatcherManager { selectedWorktreeID = worktreeID let nextRepository = worktreeID.flatMap { worktrees[$0]?.repositoryRootURL } if let previousWorktreeID { - updateLineChangeSchedule(worktreeID: previousWorktreeID, immediate: false) if let worktree = worktrees[previousWorktreeID] { configureRemoteHeadPoll(for: worktree) } } if let worktreeID { - updateLineChangeSchedule(worktreeID: worktreeID, immediate: true) + emitLineChangesChanged(worktreeID: worktreeID) if let worktree = worktrees[worktreeID] { configureRemoteHeadPoll(for: worktree) } } if let previousRepository, previousRepository == nextRepository { - updatePullRequestSchedule( - repositoryRootURL: previousRepository, - immediate: shouldImmediatelyRefreshPullRequests(repositoryRootURL: previousRepository) - ) + if shouldImmediatelyRefreshPullRequests(repositoryRootURL: previousRepository) { + refreshPullRequests(repositoryRootURL: previousRepository) + } return } - if let previousRepository { - updatePullRequestSchedule(repositoryRootURL: previousRepository, immediate: false) - } - if let nextRepository { - updatePullRequestSchedule( - repositoryRootURL: nextRepository, - immediate: shouldImmediatelyRefreshPullRequests(repositoryRootURL: nextRepository) - ) + if let nextRepository, shouldImmediatelyRefreshPullRequests(repositoryRootURL: nextRepository) { + refreshPullRequests(repositoryRootURL: nextRepository) } } @@ -202,6 +266,8 @@ final class WorktreeInfoWatcherManager { // route them to the SSH poll loop and skip the local head-file resolver // (which would return nil for a non-local path and silently drop the row). if worktree.host != nil { + stopHeadWatcher(for: worktree.id) + stopFileEventMonitor(for: worktree.id) configureRemoteHeadPoll(for: worktree) return } @@ -215,9 +281,11 @@ final class WorktreeInfoWatcherManager { return } if let existing = headWatchers[worktree.id], existing.headURL == headURL { + configureFileEventMonitor(for: worktree) return } stopWatcher(for: worktree.id) + configureFileEventMonitor(for: worktree) startWatcher(worktreeID: worktree.id, headURL: headURL) } @@ -247,6 +315,18 @@ final class WorktreeInfoWatcherManager { headWatchers[worktreeID] = HeadWatcher(headURL: headURL, source: source) } + private func configureFileEventMonitor(for worktree: Worktree) { + if let existing = fileEventMonitors[worktree.id], existing.rootURL == worktree.workingDirectory { + return + } + stopFileEventMonitor(for: worktree.id) + fileEventMonitors[worktree.id] = WorktreeFileEventMonitor( + rootURL: worktree.workingDirectory + ) { [weak self] in + self?.scheduleFilesChanged(worktreeID: worktree.id) + } + } + private func handleEvent( worktreeID: Worktree.ID, event: DispatchSource.FileSystemEvent @@ -266,8 +346,11 @@ final class WorktreeInfoWatcherManager { let sleep = self.sleep let task = Task { [weak self, sleep] in try? await sleep(.milliseconds(200)) + guard !Task.isCancelled else { + return + } await MainActor.run { - self?.emit(.branchChanged(worktreeID: worktreeID)) + self?.emitBranchChanged(worktreeID: worktreeID) } } branchDebounceTasks[worktreeID] = task @@ -279,16 +362,12 @@ final class WorktreeInfoWatcherManager { let sleep = self.sleep let task = Task { [weak self, sleep] in try? await sleep(debounceInterval) + guard !Task.isCancelled else { + return + } 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 - ) - } + self.emitLineChangesChanged(worktreeID: worktreeID) } } filesDebounceTasks[worktreeID] = task @@ -299,6 +378,9 @@ final class WorktreeInfoWatcherManager { let sleep = self.sleep let task = Task { [weak self, sleep] in try? await sleep(.seconds(5)) + guard !Task.isCancelled else { + return + } await MainActor.run { self?.restartWatcher(worktreeID: worktreeID) } @@ -326,7 +408,7 @@ final class WorktreeInfoWatcherManager { return } let worktreeID = worktree.id - let interval = worktreeID == selectedWorktreeID ? refreshTiming.focused : refreshTiming.unfocused + let interval = refreshInterval(for: worktreeID) if let existing = remoteHeadPollTasks[worktreeID], existing.interval == interval { return } @@ -374,19 +456,37 @@ final class WorktreeInfoWatcherManager { } } + private func stopFileEventMonitor(for worktreeID: Worktree.ID) { + fileEventMonitors.removeValue(forKey: worktreeID)?.cancel() + } + private func stopWatcher(for worktreeID: Worktree.ID) { stopHeadWatcher(for: worktreeID) + stopFileEventMonitor(for: worktreeID) stopRemoteHeadPoll(for: worktreeID) branchDebounceTasks.removeValue(forKey: worktreeID)?.cancel() filesDebounceTasks.removeValue(forKey: worktreeID)?.cancel() restartTasks.removeValue(forKey: worktreeID)?.cancel() - lineChangeTasks.removeValue(forKey: worktreeID)?.task.cancel() + lineChangeRefreshTasks.removeValue(forKey: worktreeID)?.cancel() } private func stopAll() { + stopBackgroundRefreshTasks() + deferredLineChangeIDs.removeAll() + hasCompletedInitialWorktreeLoad = false + worktrees.removeAll() + selectedWorktreeID = nil + pullRequestTrackingEnabled = true + eventContinuation?.finish() + } + + private func stopBackgroundRefreshTasks() { for watcher in headWatchers.values { watcher.source.cancel() } + for monitor in fileEventMonitors.values { + monitor.cancel() + } for task in branchDebounceTasks.values { task.cancel() } @@ -396,30 +496,21 @@ final class WorktreeInfoWatcherManager { for task in restartTasks.values { task.cancel() } - for task in pullRequestTasks.values { - task.task.cancel() - } - for task in lineChangeTasks.values { - task.task.cancel() + for task in lineChangeRefreshTasks.values { + task.cancel() } for task in remoteHeadPollTasks.values { task.task.cancel() } headWatchers.removeAll() + fileEventMonitors.removeAll() branchDebounceTasks.removeAll() filesDebounceTasks.removeAll() restartTasks.removeAll() - pullRequestTasks.removeAll() - lineChangeTasks.removeAll() + lineChangeRefreshTasks.removeAll() remoteHeadPollTasks.removeAll() lastKnownRemoteBranch.removeAll() - deferredLineChangeIDs.removeAll() - hasCompletedInitialWorktreeLoad = false cancelAllPullRequestSelectionCooldownTasks() - worktrees.removeAll() - selectedWorktreeID = nil - pullRequestTrackingEnabled = true - eventContinuation?.finish() } private func setPullRequestTrackingEnabled(_ enabled: Bool) { @@ -430,53 +521,35 @@ final class WorktreeInfoWatcherManager { if enabled { let repositoryRoots = Set(worktrees.values.map(\.repositoryRootURL)) for repositoryRootURL in repositoryRoots { - updatePullRequestSchedule(repositoryRootURL: repositoryRootURL, immediate: true) + refreshPullRequests(repositoryRootURL: repositoryRootURL) } return } - for task in pullRequestTasks.values { - task.task.cancel() - } - pullRequestTasks.removeAll() cancelAllPullRequestSelectionCooldownTasks() } - private func updatePullRequestSchedule(repositoryRootURL: URL, immediate: Bool) { + private func refreshPullRequests(repositoryRootURL: URL) { guard pullRequestTrackingEnabled else { - pullRequestTasks.removeValue(forKey: repositoryRootURL)?.task.cancel() return } let worktreeIDs = repositoryWorktreeIDs(for: repositoryRootURL) guard !worktreeIDs.isEmpty else { - pullRequestTasks.removeValue(forKey: repositoryRootURL)?.task.cancel() return } - let isFocused = selectedWorktreeID.map { worktreeIDs.contains($0) } ?? false - let interval = isFocused ? refreshTiming.focused : refreshTiming.unfocused - if let existing = pullRequestTasks[repositoryRootURL], existing.interval == interval, !immediate { - return + emit(.repositoryPullRequestRefresh(repositoryRootURL: repositoryRootURL, worktreeIDs: worktreeIDs)) + } + + private func refreshAll() { + let worktreesToRefresh = worktrees.values.sorted { $0.id.rawValue < $1.id.rawValue } + for worktree in worktreesToRefresh { + emitLineChangesChanged(worktreeID: worktree.id) } - pullRequestTasks[repositoryRootURL]?.task.cancel() - if immediate { - emitPullRequestRefresh(repositoryRootURL: repositoryRootURL) + let repositoryRoots = Set(worktrees.values.map(\.repositoryRootURL)).sorted { + $0.path(percentEncoded: false) < $1.path(percentEncoded: false) } - let sleep = self.sleep - let task = Task { [weak self, sleep] in - while !Task.isCancelled { - do { - try await sleep(interval) - } catch { - break - } - guard !Task.isCancelled else { - break - } - await MainActor.run { - self?.emitPullRequestRefresh(repositoryRootURL: repositoryRootURL) - } - } + for repositoryRootURL in repositoryRoots { + refreshPullRequests(repositoryRootURL: repositoryRootURL) } - pullRequestTasks[repositoryRootURL] = RefreshTask(interval: interval, task: task) } private func repositoryWorktreeIDs(for repositoryRootURL: URL) -> [Worktree.ID] { @@ -487,75 +560,42 @@ final class WorktreeInfoWatcherManager { .sorted { $0.rawValue < $1.rawValue } } - private func emitPullRequestRefresh(repositoryRootURL: URL) { - guard pullRequestTrackingEnabled else { + private func refreshInterval(for worktreeID: Worktree.ID) -> Duration { + worktreeID == selectedWorktreeID ? refreshTiming.focused : refreshTiming.unfocused + } + + private func scheduleLineChangeRefresh(worktreeID: Worktree.ID, delay: Duration) { + guard worktrees[worktreeID] != nil else { return } - let worktreeIDs = repositoryWorktreeIDs(for: repositoryRootURL) - guard !worktreeIDs.isEmpty else { - return + lineChangeRefreshTasks[worktreeID]?.cancel() + let sleep = self.sleep + let task = Task { [weak self, sleep] in + try? await sleep(delay) + guard !Task.isCancelled else { + return + } + await MainActor.run { + self?.lineChangeRefreshTasks.removeValue(forKey: worktreeID) + self?.emitLineChangesChanged(worktreeID: worktreeID) + } } - emit(.repositoryPullRequestRefresh(repositoryRootURL: repositoryRootURL, worktreeIDs: worktreeIDs)) + lineChangeRefreshTasks[worktreeID] = task } - private func updateLineChangeSchedule( - worktreeID: Worktree.ID, - immediate: Bool, - forceReschedule: Bool = false - ) { + private func emitLineChangesChanged(worktreeID: Worktree.ID) { guard worktrees[worktreeID] != nil else { return } - let interval = worktreeID == selectedWorktreeID ? refreshTiming.focused : refreshTiming.unfocused - let shouldEmit = immediate && !deferredLineChangeIDs.contains(worktreeID) - let request = RepeatingTaskRequest( - worktreeID: worktreeID, - interval: interval, - immediate: shouldEmit, - forceReschedule: forceReschedule, - makeEvent: { [weak self] worktreeID in - self?.deferredLineChangeIDs.remove(worktreeID) - return .filesChanged(worktreeID: worktreeID) - } - ) - updateRepeatingTask(request, tasks: &lineChangeTasks) + deferredLineChangeIDs.remove(worktreeID) + emit(.filesChanged(worktreeID: worktreeID)) } - private func updateRepeatingTask( - _ request: RepeatingTaskRequest, - tasks: inout [Worktree.ID: RefreshTask] - ) { - let worktreeID = request.worktreeID - if let existing = tasks[worktreeID], existing.interval == request.interval, !request.forceReschedule { - if request.immediate { - emit(request.makeEvent(worktreeID)) - } + private func emitBranchChanged(worktreeID: Worktree.ID) { + guard worktrees[worktreeID] != nil else { return } - tasks[worktreeID]?.task.cancel() - if request.immediate { - emit(request.makeEvent(worktreeID)) - } - let sleep = self.sleep - let task = Task { [weak self, sleep] in - while !Task.isCancelled { - do { - try await sleep(request.interval) - } catch { - if !(error is CancellationError) { - watcherLogger.error("Worktree refresh loop for \(worktreeID) ended: \(error).") - } - break - } - guard !Task.isCancelled else { - break - } - await MainActor.run { - self?.emit(request.makeEvent(worktreeID)) - } - } - } - tasks[worktreeID] = RefreshTask(interval: request.interval, task: task) + emit(.branchChanged(worktreeID: worktreeID)) } private func emit(_ event: WorktreeInfoWatcherClient.Event) { @@ -602,11 +642,7 @@ final class WorktreeInfoWatcherManager { let sleep = self.sleep let taskID = UUID() let task = Task { [weak self, sleep, taskID] in - do { - try await sleep(cooldown) - } catch { - return - } + try? await sleep(cooldown) await MainActor.run { guard let self, diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index a4d581b91..cd34dc429 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -2152,7 +2152,19 @@ struct RepositoriesFeature { case .worktreeBranchNameLoaded(let worktreeID, let name): state.updateWorktreeName(worktreeID, name: name) Self.syncSidebar(&state) - return .none + guard let repositoryID = state.repositoryID(containing: worktreeID), + let repository = state.repositories[id: repositoryID] + else { + return .none + } + return .send( + .worktreeInfoEvent( + .repositoryPullRequestRefresh( + repositoryRootURL: repository.rootURL, + worktreeIDs: repository.worktrees.map(\.id) + ) + ) + ) case .worktreeLineChangesLoaded(let worktreeID, let added, let removed): return state.updateWorktreeLineChangesEffect( diff --git a/supacodeTests/AppFeatureCommandPaletteTests.swift b/supacodeTests/AppFeatureCommandPaletteTests.swift index 829cbcdf8..847e9034c 100644 --- a/supacodeTests/AppFeatureCommandPaletteTests.swift +++ b/supacodeTests/AppFeatureCommandPaletteTests.swift @@ -56,13 +56,41 @@ struct AppFeatureCommandPaletteTests { } @Test(.dependencies) func refreshWorktreesDispatchesRefresh() async { + let watcherCommands = LockIsolated<[WorktreeInfoWatcherClient.Command]>([]) let store = TestStore(initialState: AppFeature.State()) { AppFeature() + } withDependencies: { + $0.worktreeInfoWatcher.send = { command in + watcherCommands.withValue { $0.append(command) } + } } store.exhaustivity = .off await store.send(.commandPalette(.delegate(.refreshWorktrees))) + await store.receive(\.refreshWorktreesRequested) await store.receive(\.repositories.refreshWorktrees) + await store.receive(\.repositories.reloadRepositories) + await store.finish() + + #expect(watcherCommands.value == [.refresh]) + } + + @Test(.dependencies) func repositoryRefreshDoesNotForceWorktreeInfoRefresh() async { + let watcherCommands = LockIsolated<[WorktreeInfoWatcherClient.Command]>([]) + let store = TestStore(initialState: AppFeature.State()) { + AppFeature() + } withDependencies: { + $0.worktreeInfoWatcher.send = { command in + watcherCommands.withValue { $0.append(command) } + } + } + store.exhaustivity = .off + + await store.send(.repositories(.refreshWorktrees)) + await store.receive(\.repositories.reloadRepositories) + await store.finish() + + #expect(watcherCommands.value.isEmpty) } @Test(.dependencies) func viewArchivedWorktreesDispatchesSelectArchived() async { diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index c2fe16d28..0a611c51c 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -3838,7 +3838,9 @@ struct RepositoriesFeatureTests { let worktree = makeWorktree(id: "/tmp/wt", name: "eagle", createdAt: createdAt) let renamedWorktree = makeWorktree(id: "/tmp/wt", name: "falcon", createdAt: createdAt) let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) - let store = TestStore(initialState: makeState(repositories: [repository])) { + var initialState = makeState(repositories: [repository]) + initialState.githubIntegrationAvailability = .disabled + let store = TestStore(initialState: initialState) { RepositoriesFeature() } @@ -3855,6 +3857,7 @@ struct RepositoriesFeatureTests { $0.repositories[id: repository.id] = repository $0.reconcileSidebarForTesting() } + await store.receive(\.worktreeInfoEvent) #expect(store.state.repositories[id: repository.id]?.worktrees[id: worktree.id]?.name == "falcon") #expect(store.state.repositories[id: repository.id]?.worktrees[id: worktree.id]?.createdAt == createdAt) } @@ -4953,6 +4956,42 @@ struct RepositoriesFeatureTests { #expect(batchCalls.value == [GithubRemoteInfo(host: "github.com", owner: "fork", repo: "project")]) } + @Test func worktreeBranchNameLoadedRefreshesPullRequestsWithUpdatedBranchName() async { + let repoRoot = "/tmp/repo" + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) + let featureWorktree = makeWorktree( + id: "\(repoRoot)/feature", + name: "old-feature", + repoRoot: repoRoot + ) + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) + var initialState = makeState(repositories: [repository]) + initialState.githubIntegrationAvailability = .available + let queriedBranches = LockIsolated<[String]>([]) + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } withDependencies: { + $0.githubCLI.resolveRemoteInfo = { _ in + GithubRemoteInfo(host: "github.com", owner: "upstream", repo: "project") + } + $0.githubCLI.batchPullRequests = { _, _, _, branches in + queriedBranches.withValue { $0 = branches } + return [:] + } + } + store.exhaustivity = .off + + await store.send( + .worktreeBranchNameLoaded(worktreeID: featureWorktree.id, name: "new-feature") + ) + await store.receive(\.worktreeInfoEvent) + await store.receive(\.repositoryPullRequestsLoaded) + await store.receive(\.repositoryPullRequestRefreshCompleted) + await store.finish() + + #expect(queriedBranches.value == ["main", "new-feature"]) + } + @Test func pullRequestActionMergePassesResolvedRemoteToGh() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) diff --git a/supacodeTests/WorktreeInfoWatcherManagerTests.swift b/supacodeTests/WorktreeInfoWatcherManagerTests.swift index 29f9741bf..e09cfb4bb 100644 --- a/supacodeTests/WorktreeInfoWatcherManagerTests.swift +++ b/supacodeTests/WorktreeInfoWatcherManagerTests.swift @@ -11,15 +11,21 @@ struct WorktreeInfoWatcherManagerTests { let tempWorktree = try makeTempWorktree() let manager = WorktreeInfoWatcherManager( focusedInterval: .seconds(3_600), - unfocusedInterval: .seconds(3_600) + unfocusedInterval: .seconds(3_600), + filesChangedDebounceInterval: .seconds(3_600) ) let (collector, task) = startCollecting(manager.eventStream()) manager.handleCommand(.setPullRequestTrackingEnabled(false)) manager.handleCommand(.setWorktrees([tempWorktree.worktree])) - await drainAsyncEvents(120) - #expect(await collector.filesChangedCount(worktreeID: tempWorktree.worktree.id) == 1) + #expect( + await waitForFilesChangedCount( + collector, + worktreeID: tempWorktree.worktree.id, + atLeast: 1 + ) >= 1 + ) manager.handleCommand(.stop) await task.value @@ -40,8 +46,13 @@ struct WorktreeInfoWatcherManagerTests { manager.handleCommand(.setPullRequestTrackingEnabled(false)) manager.handleCommand(.setWorktrees([firstWorktree])) - await drainAsyncEvents(120) - #expect(await collector.filesChangedCount(worktreeID: firstWorktree.id) == 1) + #expect( + await waitForFilesChangedCount( + collector, + worktreeID: firstWorktree.id, + atLeast: 1 + ) == 1 + ) manager.handleCommand(.setWorktrees([firstWorktree, secondWorktree])) await drainAsyncEvents(120) @@ -52,14 +63,74 @@ struct WorktreeInfoWatcherManagerTests { #expect(await collector.filesChangedCount(worktreeID: secondWorktree.id) == 0) await clock.advance(by: .milliseconds(1)) - await drainAsyncEvents(120) - #expect(await collector.filesChangedCount(worktreeID: secondWorktree.id) == 1) + #expect( + await waitForFilesChangedCount( + collector, + worktreeID: secondWorktree.id, + atLeast: 1 + ) == 1 + ) manager.handleCommand(.stop) await task.value try FileManager.default.removeItem(at: tempRepository.tempRoot) } + @Test func lineChangesDoNotRefreshWhileIdle() async throws { + let clock = TestClock() + let tempWorktree = try makeTempWorktree() + let manager = WorktreeInfoWatcherManager( + focusedInterval: .milliseconds(80), + unfocusedInterval: .milliseconds(80), + clock: clock + ) + let (collector, task) = startCollecting(manager.eventStream()) + + manager.handleCommand(.setPullRequestTrackingEnabled(false)) + manager.handleCommand(.setWorktrees([tempWorktree.worktree])) + #expect( + await waitForFilesChangedCount( + collector, + worktreeID: tempWorktree.worktree.id, + atLeast: 1 + ) == 1 + ) + + await clock.advance(by: .seconds(1)) + await drainAsyncEvents(120) + #expect(await collector.filesChangedCount(worktreeID: tempWorktree.worktree.id) == 1) + + manager.handleCommand(.stop) + await task.value + try FileManager.default.removeItem(at: tempWorktree.tempRoot) + } + + @Test func unchangedWorktreesDoNotRefreshLineChanges() async throws { + let tempWorktree = try makeTempWorktree() + let manager = WorktreeInfoWatcherManager( + filesChangedDebounceInterval: .seconds(3_600) + ) + let (collector, task) = startCollecting(manager.eventStream()) + + manager.handleCommand(.setPullRequestTrackingEnabled(false)) + manager.handleCommand(.setWorktrees([tempWorktree.worktree])) + #expect( + await waitForFilesChangedCount( + collector, + worktreeID: tempWorktree.worktree.id, + atLeast: 1 + ) == 1 + ) + + manager.handleCommand(.setWorktrees([tempWorktree.worktree])) + await drainAsyncEvents(120) + #expect(await collector.filesChangedCount(worktreeID: tempWorktree.worktree.id) == 1) + + manager.handleCommand(.stop) + await task.value + try FileManager.default.removeItem(at: tempWorktree.tempRoot) + } + @Test func buildsWorktreeLookupWithoutTrappingOnDuplicateID() async throws { // Two entries sharing one WorktreeID must not trap; the first entry wins. let tempWorktree = try makeTempWorktree() @@ -72,16 +143,22 @@ struct WorktreeInfoWatcherManagerTests { ) let manager = WorktreeInfoWatcherManager( focusedInterval: .seconds(3_600), - unfocusedInterval: .seconds(3_600) + unfocusedInterval: .seconds(3_600), + filesChangedDebounceInterval: .seconds(3_600) ) let (collector, task) = startCollecting(manager.eventStream()) manager.handleCommand(.setPullRequestTrackingEnabled(false)) manager.handleCommand(.setWorktrees([tempWorktree.worktree, duplicate])) - await drainAsyncEvents(120) // The manager initialized and the single de-duplicated worktree is watched. - #expect(await collector.filesChangedCount(worktreeID: tempWorktree.worktree.id) == 1) + #expect( + await waitForFilesChangedCount( + collector, + worktreeID: tempWorktree.worktree.id, + atLeast: 1 + ) == 1 + ) manager.handleCommand(.stop) await task.value @@ -157,6 +234,117 @@ struct WorktreeInfoWatcherManagerTests { await task.value } + @Test func branchChangesDoNotRefreshPullRequestsBeforeBranchNameLoads() async throws { + let clock = TestClock() + let stub = RemoteBranchPollStub(responses: ["main", "feature"]) + let manager = WorktreeInfoWatcherManager( + focusedInterval: .milliseconds(500), + unfocusedInterval: .milliseconds(500), + clock: clock, + pollRemoteBranch: { _ in await stub.next() } + ) + let (collector, task) = startCollecting(manager.eventStream()) + let remote = makeRemoteWorktree(name: "remote-eagle") + + manager.handleCommand(.setWorktrees([remote])) + await drainAsyncEvents(200) + let baselineCount = await collector.pullRequestRefreshCount( + repositoryRootURL: remote.repositoryRootURL + ) + #expect(baselineCount == 1) + + // Branch changes only emit branchChanged here. The reducer refreshes PR + // state after loading the new branch name into repository state. + await clock.advance(by: .milliseconds(200)) + await drainAsyncEvents(200) + let afterInitialBranchObservationCount = await collector.pullRequestRefreshCount( + repositoryRootURL: remote.repositoryRootURL + ) + #expect(afterInitialBranchObservationCount == baselineCount) + #expect(await collector.branchChangedCount(worktreeID: remote.id) == 1) + + await clock.advance(by: .milliseconds(300)) + await drainAsyncEvents(200) + await clock.advance(by: .milliseconds(200)) + await drainAsyncEvents(200) + let afterBranchChangeCount = await collector.pullRequestRefreshCount( + repositoryRootURL: remote.repositoryRootURL + ) + #expect(afterBranchChangeCount == baselineCount) + #expect(await collector.branchChangedCount(worktreeID: remote.id) == 2) + + // Stable remote branch polls must not keep refreshing PR state. + await clock.advance(by: .milliseconds(500)) + await drainAsyncEvents(200) + await clock.advance(by: .milliseconds(200)) + await drainAsyncEvents(200) + let afterStableBranchPollCount = await collector.pullRequestRefreshCount( + repositoryRootURL: remote.repositoryRootURL + ) + #expect(afterStableBranchPollCount == afterBranchChangeCount) + + manager.handleCommand(.stop) + await task.value + } + + @Test func refreshCommandRefreshesLineChangesAndPullRequests() async throws { + let tempRepository = try makeTempRepository(worktreeNames: ["sparrow", "swift"]) + let manager = WorktreeInfoWatcherManager( + filesChangedDebounceInterval: .seconds(3_600) + ) + let (collector, task) = startCollecting(manager.eventStream()) + let firstWorktree = try #require(tempRepository.worktrees.first) + let secondWorktree = try #require(tempRepository.worktrees.dropFirst().first) + + manager.handleCommand(.setWorktrees(tempRepository.worktrees)) + let firstBaselineCount = await waitForFilesChangedCount( + collector, + worktreeID: firstWorktree.id, + atLeast: 1 + ) + let secondBaselineCount = await waitForFilesChangedCount( + collector, + worktreeID: secondWorktree.id, + atLeast: 1 + ) + #expect(firstBaselineCount == 1) + #expect(secondBaselineCount == 1) + let baselinePullRequestCount = await waitForPullRequestRefreshCount( + collector, + repositoryRootURL: tempRepository.tempRoot, + atLeast: 1 + ) + #expect(baselinePullRequestCount == 1) + + manager.handleCommand(.refresh) + #expect( + await waitForFilesChangedCount( + collector, + worktreeID: firstWorktree.id, + atLeast: firstBaselineCount + 1 + ) == firstBaselineCount + 1 + ) + #expect( + await waitForFilesChangedCount( + collector, + worktreeID: secondWorktree.id, + atLeast: secondBaselineCount + 1 + ) == secondBaselineCount + 1 + ) + #expect( + await waitForPullRequestRefreshCount( + collector, + repositoryRootURL: tempRepository.tempRoot, + atLeast: baselinePullRequestCount + 1 + ) + == baselinePullRequestCount + 1 + ) + + manager.handleCommand(.stop) + await task.value + try FileManager.default.removeItem(at: tempRepository.tempRoot) + } + @Test func selectionRefreshUsesCooldownWithinRepository() async throws { let clock = TestClock() let tempRepository = try makeTempRepository(worktreeNames: ["sparrow", "swift"]) @@ -198,6 +386,59 @@ struct WorktreeInfoWatcherManagerTests { try FileManager.default.removeItem(at: tempRepository.tempRoot) } + @Test func pullRequestsDoNotRefreshWhileIdle() async throws { + let clock = TestClock() + let tempRepository = try makeTempRepository(worktreeNames: ["sparrow", "swift"]) + let manager = WorktreeInfoWatcherManager( + focusedInterval: .milliseconds(80), + unfocusedInterval: .milliseconds(80), + clock: clock + ) + let (collector, task) = startCollecting(manager.eventStream()) + + manager.handleCommand(.setWorktrees(tempRepository.worktrees)) + await drainAsyncEvents(120) + let baselineCount = await collector.pullRequestRefreshCount( + repositoryRootURL: tempRepository.tempRoot + ) + #expect(baselineCount == 1) + + await clock.advance(by: .seconds(1)) + await drainAsyncEvents(120) + let afterIdleCount = await collector.pullRequestRefreshCount( + repositoryRootURL: tempRepository.tempRoot + ) + #expect(afterIdleCount == baselineCount) + + manager.handleCommand(.stop) + await task.value + try FileManager.default.removeItem(at: tempRepository.tempRoot) + } + + @Test func unchangedWorktreesDoNotRefreshPullRequests() async throws { + let tempRepository = try makeTempRepository(worktreeNames: ["sparrow", "swift"]) + let manager = WorktreeInfoWatcherManager() + let (collector, task) = startCollecting(manager.eventStream()) + + manager.handleCommand(.setWorktrees(tempRepository.worktrees)) + await drainAsyncEvents(120) + let baselineCount = await collector.pullRequestRefreshCount( + repositoryRootURL: tempRepository.tempRoot + ) + #expect(baselineCount == 1) + + manager.handleCommand(.setWorktrees(tempRepository.worktrees)) + await drainAsyncEvents(120) + let afterUnchangedWorktreesCount = await collector.pullRequestRefreshCount( + repositoryRootURL: tempRepository.tempRoot + ) + #expect(afterUnchangedWorktreesCount == baselineCount) + + manager.handleCommand(.stop) + await task.value + try FileManager.default.removeItem(at: tempRepository.tempRoot) + } + @Test func canceledSelectionCooldownDoesNotClearReplacementCooldown() async throws { let clock = TestClock() let tempRepository = try makeTempRepository(worktreeNames: ["sparrow", "swift"]) @@ -251,11 +492,19 @@ struct WorktreeInfoWatcherManagerTests { manager.handleCommand(.setPullRequestTrackingEnabled(false)) let stream = manager.eventStream() - // Each setWorktrees re-emits an immediate filesChanged for the worktree; - // with nothing draining, the buffer must cap rather than grow unbounded. + // Metadata changes still emit refresh signals; with nothing draining, the + // stream must cap rather than grow unbounded. let overflow = WorktreeInfoWatcherManager.eventBufferCap + 50 - for _ in 0.. Int { + for _ in 0..= expectedCount { + return count + } + await Task.yield() + } + return await collector.filesChangedCount(worktreeID: worktreeID) +} + +private func waitForPullRequestRefreshCount( + _ collector: EventCollector, + repositoryRootURL: URL, + atLeast expectedCount: Int, + iterations: Int = 200 +) async -> Int { + for _ in 0..= expectedCount { + return count + } + await Task.yield() + } + return await collector.pullRequestRefreshCount(repositoryRootURL: repositoryRootURL) +}