Skip to content

Commit 9589a09

Browse files
authored
Merge pull request #25 from sidequery/nicosuave/sidebar-fetch-audit
Improve Worktrunk sidebar refresh performance
2 parents 32eabea + 5a3617a commit 9589a09

2 files changed

Lines changed: 99 additions & 14 deletions

File tree

macos/Sources/Features/Worktrunk/WorktrunkSidebarView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ struct WorktrunkSidebarView: View {
194194
)
195195
}
196196
clearSelectionIfMainInFlatMode()
197-
Task { await store.refreshAll() }
197+
Task { await store.refreshForSidebarAppearIfNeeded() }
198198
}
199199
.alert(
200200
"Remove Repository?",

macos/Sources/Features/Worktrunk/WorktrunkStore.swift

Lines changed: 98 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,13 @@ enum WorktrunkSidebarListMode: String {
190190
}
191191

192192
final class WorktrunkStore: ObservableObject {
193+
private enum RefreshAllTrigger {
194+
case sidebarAppear
195+
case repositoryAdded
196+
case worktrunkInstalled
197+
case manual
198+
}
199+
193200
struct Repository: Identifiable, Codable, Hashable {
194201
var id: UUID
195202
var path: String
@@ -306,7 +313,12 @@ final class WorktrunkStore: ObservableObject {
306313
private var lastAppQuitTimestamp: Date?
307314
private var sidebarModelRevisionCounter: Int = 0
308315
private var refreshAllTask: Task<Void, Never>?
316+
private var refreshAllTaskGeneration: UInt64 = 0
309317
private var refreshAllNeedsRerun: Bool = false
318+
private var lastRefreshAllCompletedAt: Date = .distantPast
319+
private let sidebarAppearRefreshInterval: TimeInterval = 20
320+
private let repoListRefreshConcurrency: Int = 4
321+
private let gitTrackingRefreshConcurrency: Int = 8
310322

311323
init() {
312324
load()
@@ -527,7 +539,7 @@ final class WorktrunkStore: ObservableObject {
527539
save()
528540
rebuildSidebarSnapshot()
529541
bumpSidebarModelRevision()
530-
Task { await refreshAll() }
542+
Task { await refreshAll(trigger: .repositoryAdded) }
531543
}
532544

533545
func removeRepository(id: UUID) {
@@ -541,23 +553,55 @@ final class WorktrunkStore: ObservableObject {
541553
}
542554

543555
func refreshAll() async {
556+
await refreshAll(trigger: .manual)
557+
}
558+
559+
func refreshForSidebarAppearIfNeeded() async {
560+
await refreshAll(trigger: .sidebarAppear)
561+
}
562+
563+
private func refreshAll(trigger: RefreshAllTrigger) async {
564+
if trigger == .sidebarAppear, shouldSkipSidebarAppearRefresh() {
565+
return
566+
}
567+
568+
let shouldScheduleRerun = shouldScheduleRefreshRerun(for: trigger)
544569
if let existing = refreshAllTask {
545-
refreshAllNeedsRerun = true
570+
let observedGeneration = refreshAllTaskGeneration
571+
if shouldScheduleRerun {
572+
refreshAllNeedsRerun = true
573+
}
546574
await existing.value
547-
if refreshAllNeedsRerun {
548-
refreshAllNeedsRerun = false
549-
await refreshAll()
575+
576+
// Existing task may be complete but still stored here until its creator resumes.
577+
if refreshAllTaskGeneration == observedGeneration {
578+
refreshAllTask = nil
579+
}
580+
581+
if shouldScheduleRerun, refreshAllNeedsRerun, refreshAllTask == nil {
582+
await startRefreshAllTask()
550583
}
551584
return
552585
}
553586

587+
await startRefreshAllTask()
588+
}
589+
590+
private func startRefreshAllTask() async {
591+
guard refreshAllTask == nil else { return }
592+
593+
refreshAllTaskGeneration &+= 1
594+
let generation = refreshAllTaskGeneration
554595
let task = Task { [weak self] in
555596
guard let self else { return }
556597
await self.refreshAllBatchedLoop()
557598
}
558599
refreshAllTask = task
559600
await task.value
560-
refreshAllTask = nil
601+
602+
if refreshAllTaskGeneration == generation {
603+
refreshAllTask = nil
604+
}
561605
}
562606

563607
private struct RefreshListResult {
@@ -577,10 +621,27 @@ final class WorktrunkStore: ObservableObject {
577621
await refreshAllBatchedOnce()
578622
} while refreshAllNeedsRerun
579623
await MainActor.run {
624+
lastRefreshAllCompletedAt = Date()
580625
isRefreshing = false
581626
}
582627
}
583628

629+
private func shouldSkipSidebarAppearRefresh() -> Bool {
630+
if refreshAllTask != nil {
631+
return false
632+
}
633+
return Date().timeIntervalSince(lastRefreshAllCompletedAt) < sidebarAppearRefreshInterval
634+
}
635+
636+
private func shouldScheduleRefreshRerun(for trigger: RefreshAllTrigger) -> Bool {
637+
switch trigger {
638+
case .sidebarAppear:
639+
return false
640+
case .repositoryAdded, .worktrunkInstalled, .manual:
641+
return true
642+
}
643+
}
644+
584645
private func refreshAllBatchedOnce() async {
585646
let repoSnapshot = await MainActor.run { repositories }
586647
let previousByRepoID = await MainActor.run {
@@ -597,7 +658,12 @@ final class WorktrunkStore: ObservableObject {
597658
results.reserveCapacity(repoSnapshot.count)
598659

599660
await withTaskGroup(of: RefreshListResult.self) { group in
600-
for repo in repoSnapshot {
661+
var nextRepoIndex = 0
662+
663+
func enqueueNextRepo() {
664+
guard nextRepoIndex < repoSnapshot.count else { return }
665+
let repo = repoSnapshot[nextRepoIndex]
666+
nextRepoIndex += 1
601667
let previous = previousByRepoID[repo.id] ?? (hadExisting: false, paths: Set<String>())
602668
group.addTask { [self] in
603669
do {
@@ -624,8 +690,13 @@ final class WorktrunkStore: ObservableObject {
624690
}
625691
}
626692

627-
for await result in group {
693+
for _ in 0..<min(repoListRefreshConcurrency, repoSnapshot.count) {
694+
enqueueNextRepo()
695+
}
696+
697+
while let result = await group.next() {
628698
results.append(result)
699+
enqueueNextRepo()
629700
}
630701
}
631702

@@ -678,8 +749,9 @@ final class WorktrunkStore: ObservableObject {
678749
repositories.flatMap { worktreesByRepositoryID[$0.id] ?? [] }
679750
}
680751

681-
await refreshGitTracking(for: allWorktrees, removing: allPreviousPaths)
682-
await refreshSessions()
752+
async let trackingRefresh: Void = refreshGitTracking(for: allWorktrees, removing: allPreviousPaths)
753+
async let sessionsRefresh: Void = refreshSessions()
754+
_ = await (trackingRefresh, sessionsRefresh)
683755
}
684756

685757
private func decodeWorktrees(repoID: UUID, data: Data) throws -> [Worktree] {
@@ -818,7 +890,7 @@ final class WorktrunkStore: ObservableObject {
818890
errorMessage = nil
819891
needsWorktrunkInstall = false
820892
}
821-
await refreshAll()
893+
await refreshAll(trigger: .worktrunkInstalled)
822894
return true
823895
} catch {
824896
await MainActor.run {
@@ -1001,17 +1073,27 @@ final class WorktrunkStore: ObservableObject {
10011073
var results: [String: GitTracking] = [:]
10021074

10031075
await withTaskGroup(of: (String, GitTracking?).self) { group in
1004-
for worktree in worktrees {
1076+
var nextWorktreeIndex = 0
1077+
1078+
func enqueueNextWorktree() {
1079+
guard nextWorktreeIndex < worktrees.count else { return }
1080+
let worktree = worktrees[nextWorktreeIndex]
1081+
nextWorktreeIndex += 1
10051082
group.addTask { [self] in
10061083
let tracking = try? await getGitTracking(worktreePath: worktree.path)
10071084
return (worktree.path, tracking)
10081085
}
10091086
}
10101087

1011-
for await (path, tracking) in group {
1088+
for _ in 0..<min(gitTrackingRefreshConcurrency, worktrees.count) {
1089+
enqueueNextWorktree()
1090+
}
1091+
1092+
while let (path, tracking) = await group.next() {
10121093
if let tracking {
10131094
results[path] = tracking
10141095
}
1096+
enqueueNextWorktree()
10151097
}
10161098
}
10171099

@@ -1037,6 +1119,9 @@ final class WorktrunkStore: ObservableObject {
10371119
let output = try await runGitStatus(worktreePath: worktreePath)
10381120
if output.isEmpty { return nil }
10391121
var parsed = parseGitStatusOutput(output)
1122+
if parsed.tracking.totalChangesCount == 0 {
1123+
return parsed.tracking
1124+
}
10401125

10411126
let (unstagedAdds, unstagedDeletes) = (try? await runGitNumstat(
10421127
worktreePath: worktreePath,

0 commit comments

Comments
 (0)