Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ struct WorktrunkSidebarView: View {
)
}
clearSelectionIfMainInFlatMode()
Task { await store.refreshAll() }
Task { await store.refreshForSidebarAppearIfNeeded() }
}
.alert(
"Remove Repository?",
Expand Down
111 changes: 98 additions & 13 deletions macos/Sources/Features/Worktrunk/WorktrunkStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -302,7 +309,12 @@ final class WorktrunkStore: ObservableObject {
private var lastAppQuitTimestamp: Date?
private var sidebarModelRevisionCounter: Int = 0
private var refreshAllTask: Task<Void, Never>?
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()
Expand Down Expand Up @@ -523,7 +535,7 @@ final class WorktrunkStore: ObservableObject {
save()
rebuildSidebarSnapshot()
bumpSidebarModelRevision()
Task { await refreshAll() }
Task { await refreshAll(trigger: .repositoryAdded) }
}

func removeRepository(id: UUID) {
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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<String>())
group.addTask { [self] in
do {
Expand All @@ -620,8 +686,13 @@ final class WorktrunkStore: ObservableObject {
}
}

for await result in group {
for _ in 0..<min(repoListRefreshConcurrency, repoSnapshot.count) {
enqueueNextRepo()
}

while let result = await group.next() {
results.append(result)
enqueueNextRepo()
}
}

Expand Down Expand Up @@ -674,8 +745,9 @@ final class WorktrunkStore: ObservableObject {
repositories.flatMap { worktreesByRepositoryID[$0.id] ?? [] }
}

await refreshGitTracking(for: allWorktrees, removing: allPreviousPaths)
await refreshSessions()
async let trackingRefresh: Void = refreshGitTracking(for: allWorktrees, removing: allPreviousPaths)
async let sessionsRefresh: Void = refreshSessions()
_ = await (trackingRefresh, sessionsRefresh)
}

private func decodeWorktrees(repoID: UUID, data: Data) throws -> [Worktree] {
Expand Down Expand Up @@ -814,7 +886,7 @@ final class WorktrunkStore: ObservableObject {
errorMessage = nil
needsWorktrunkInstall = false
}
await refreshAll()
await refreshAll(trigger: .worktrunkInstalled)
return true
} catch {
await MainActor.run {
Expand Down Expand Up @@ -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..<min(gitTrackingRefreshConcurrency, worktrees.count) {
enqueueNextWorktree()
}

while let (path, tracking) = await group.next() {
if let tracking {
results[path] = tracking
}
enqueueNextWorktree()
}
}

Expand All @@ -1033,6 +1115,9 @@ final class WorktrunkStore: ObservableObject {
let output = try await runGitStatus(worktreePath: worktreePath)
if output.isEmpty { return nil }
var parsed = parseGitStatusOutput(output)
if parsed.tracking.totalChangesCount == 0 {
return parsed.tracking
}

let (unstagedAdds, unstagedDeletes) = (try? await runGitNumstat(
worktreePath: worktreePath,
Expand Down
Loading