diff --git a/macos/Sources/Features/Worktrunk/WorktrunkSidebarView.swift b/macos/Sources/Features/Worktrunk/WorktrunkSidebarView.swift index 5fe19ac5de..5f883c3a8a 100644 --- a/macos/Sources/Features/Worktrunk/WorktrunkSidebarView.swift +++ b/macos/Sources/Features/Worktrunk/WorktrunkSidebarView.swift @@ -194,7 +194,7 @@ struct WorktrunkSidebarView: View { ) } clearSelectionIfMainInFlatMode() - Task { await store.refreshAll() } + Task { await store.refreshForSidebarAppearIfNeeded() } } .alert( "Remove Repository?", diff --git a/macos/Sources/Features/Worktrunk/WorktrunkStore.swift b/macos/Sources/Features/Worktrunk/WorktrunkStore.swift index 6aaa8e5705..37705c02e4 100644 --- a/macos/Sources/Features/Worktrunk/WorktrunkStore.swift +++ b/macos/Sources/Features/Worktrunk/WorktrunkStore.swift @@ -186,6 +186,13 @@ enum WorktrunkSidebarListMode: String { } final class WorktrunkStore: ObservableObject { + private enum RefreshAllTrigger { + case sidebarAppear + case repositoryAdded + case worktrunkInstalled + case manual + } + struct Repository: Identifiable, Codable, Hashable { var id: UUID var path: String @@ -302,7 +309,12 @@ final class WorktrunkStore: ObservableObject { private var lastAppQuitTimestamp: Date? private var sidebarModelRevisionCounter: Int = 0 private var refreshAllTask: Task? + private var refreshAllTaskGeneration: UInt64 = 0 private var refreshAllNeedsRerun: Bool = false + private var lastRefreshAllCompletedAt: Date = .distantPast + private let sidebarAppearRefreshInterval: TimeInterval = 20 + private let repoListRefreshConcurrency: Int = 4 + private let gitTrackingRefreshConcurrency: Int = 8 init() { load() @@ -523,7 +535,7 @@ final class WorktrunkStore: ObservableObject { save() rebuildSidebarSnapshot() bumpSidebarModelRevision() - Task { await refreshAll() } + Task { await refreshAll(trigger: .repositoryAdded) } } func removeRepository(id: UUID) { @@ -537,23 +549,55 @@ final class WorktrunkStore: ObservableObject { } func refreshAll() async { + await refreshAll(trigger: .manual) + } + + func refreshForSidebarAppearIfNeeded() async { + await refreshAll(trigger: .sidebarAppear) + } + + private func refreshAll(trigger: RefreshAllTrigger) async { + if trigger == .sidebarAppear, shouldSkipSidebarAppearRefresh() { + return + } + + let shouldScheduleRerun = shouldScheduleRefreshRerun(for: trigger) if let existing = refreshAllTask { - refreshAllNeedsRerun = true + let observedGeneration = refreshAllTaskGeneration + if shouldScheduleRerun { + refreshAllNeedsRerun = true + } await existing.value - if refreshAllNeedsRerun { - refreshAllNeedsRerun = false - await refreshAll() + + // Existing task may be complete but still stored here until its creator resumes. + if refreshAllTaskGeneration == observedGeneration { + refreshAllTask = nil + } + + if shouldScheduleRerun, refreshAllNeedsRerun, refreshAllTask == nil { + await startRefreshAllTask() } return } + await startRefreshAllTask() + } + + private func startRefreshAllTask() async { + guard refreshAllTask == nil else { return } + + refreshAllTaskGeneration &+= 1 + let generation = refreshAllTaskGeneration let task = Task { [weak self] in guard let self else { return } await self.refreshAllBatchedLoop() } refreshAllTask = task await task.value - refreshAllTask = nil + + if refreshAllTaskGeneration == generation { + refreshAllTask = nil + } } private struct RefreshListResult { @@ -573,10 +617,27 @@ final class WorktrunkStore: ObservableObject { await refreshAllBatchedOnce() } while refreshAllNeedsRerun await MainActor.run { + lastRefreshAllCompletedAt = Date() isRefreshing = false } } + private func shouldSkipSidebarAppearRefresh() -> Bool { + if refreshAllTask != nil { + return false + } + return Date().timeIntervalSince(lastRefreshAllCompletedAt) < sidebarAppearRefreshInterval + } + + private func shouldScheduleRefreshRerun(for trigger: RefreshAllTrigger) -> Bool { + switch trigger { + case .sidebarAppear: + return false + case .repositoryAdded, .worktrunkInstalled, .manual: + return true + } + } + private func refreshAllBatchedOnce() async { let repoSnapshot = await MainActor.run { repositories } let previousByRepoID = await MainActor.run { @@ -593,7 +654,12 @@ final class WorktrunkStore: ObservableObject { results.reserveCapacity(repoSnapshot.count) await withTaskGroup(of: RefreshListResult.self) { group in - for repo in repoSnapshot { + var nextRepoIndex = 0 + + func enqueueNextRepo() { + guard nextRepoIndex < repoSnapshot.count else { return } + let repo = repoSnapshot[nextRepoIndex] + nextRepoIndex += 1 let previous = previousByRepoID[repo.id] ?? (hadExisting: false, paths: Set()) group.addTask { [self] in do { @@ -620,8 +686,13 @@ final class WorktrunkStore: ObservableObject { } } - for await result in group { + for _ in 0.. [Worktree] { @@ -814,7 +886,7 @@ final class WorktrunkStore: ObservableObject { errorMessage = nil needsWorktrunkInstall = false } - await refreshAll() + await refreshAll(trigger: .worktrunkInstalled) return true } catch { await MainActor.run { @@ -997,17 +1069,27 @@ final class WorktrunkStore: ObservableObject { var results: [String: GitTracking] = [:] await withTaskGroup(of: (String, GitTracking?).self) { group in - for worktree in worktrees { + var nextWorktreeIndex = 0 + + func enqueueNextWorktree() { + guard nextWorktreeIndex < worktrees.count else { return } + let worktree = worktrees[nextWorktreeIndex] + nextWorktreeIndex += 1 group.addTask { [self] in let tracking = try? await getGitTracking(worktreePath: worktree.path) return (worktree.path, tracking) } } - for await (path, tracking) in group { + for _ in 0..