diff --git a/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift b/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift index c8bda1f5..a78e228b 100644 --- a/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift +++ b/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift @@ -83,6 +83,7 @@ final class WorktreeInfoWatcherManager { private var selectedWorktreeID: Worktree.ID? private var pullRequestTrackingEnabled = true private var pullRequestSelectionCooldownTasksByRepo: [URL: PullRequestSelectionCooldownTask] = [:] + private var lastSelectedWorktreeIDByRepo: [URL: Worktree.ID] = [:] private var eventContinuation: AsyncStream.Continuation? init>( @@ -220,21 +221,39 @@ final class WorktreeInfoWatcherManager { emitLineChangesChanged(worktreeID: worktreeID) syncLineChangesActivity(for: worktreeID) } + // When switching to a different worktree within the same repo, cancel any active cooldown + // so the PR refresh fires immediately. Re-selecting the same worktree still respects the cooldown. if let previousRepository, previousRepository == nextRepository { + let lastWorktreeForRepo = lastSelectedWorktreeIDByRepo[previousRepository] + let isDifferentWorktree = lastWorktreeForRepo != worktreeID + if isDifferentWorktree { + cancelPullRequestSelectionCooldown(for: previousRepository) + } updatePullRequestSchedule( repositoryRootURL: previousRepository, immediate: shouldImmediatelyRefreshPullRequests(repositoryRootURL: previousRepository) ) + if let worktreeID { + lastSelectedWorktreeIDByRepo[previousRepository] = worktreeID + } return } if let previousRepository { updatePullRequestSchedule(repositoryRootURL: previousRepository, immediate: false) } if let nextRepository { + let lastWorktreeForRepo = lastSelectedWorktreeIDByRepo[nextRepository] + let isDifferentWorktree = lastWorktreeForRepo != worktreeID + if isDifferentWorktree { + cancelPullRequestSelectionCooldown(for: nextRepository) + } updatePullRequestSchedule( repositoryRootURL: nextRepository, immediate: shouldImmediatelyRefreshPullRequests(repositoryRootURL: nextRepository) ) + if let worktreeID { + lastSelectedWorktreeIDByRepo[nextRepository] = worktreeID + } } } @@ -413,6 +432,7 @@ final class WorktreeInfoWatcherManager { openedWorktreeIDs.removeAll() hasCompletedInitialWorktreeLoad = false cancelAllPullRequestSelectionCooldownTasks() + lastSelectedWorktreeIDByRepo.removeAll() worktrees.removeAll() selectedWorktreeID = nil pullRequestTrackingEnabled = true diff --git a/supacodeTests/WorktreeInfoWatcherManagerTests.swift b/supacodeTests/WorktreeInfoWatcherManagerTests.swift index 02efdd3a..0cbc5abf 100644 --- a/supacodeTests/WorktreeInfoWatcherManagerTests.swift +++ b/supacodeTests/WorktreeInfoWatcherManagerTests.swift @@ -378,17 +378,63 @@ struct WorktreeInfoWatcherManagerTests { await clock.advance(by: .milliseconds(500)) await drainAsyncEvents() + // Selecting first worktree triggers immediate refresh. manager.handleCommand(.setSelectedWorktreeID(firstWorktree.id)) await drainAsyncEvents() #expect(await collector.pullRequestRefreshCount(repositoryRootURL: tempRepository.tempRoot) == baselineCount + 1) + // Switching to a different worktree cancels cooldown and triggers immediate refresh. manager.handleCommand(.setSelectedWorktreeID(secondWorktree.id)) await drainAsyncEvents() - #expect(await collector.pullRequestRefreshCount(repositoryRootURL: tempRepository.tempRoot) == baselineCount + 1) + #expect(await collector.pullRequestRefreshCount(repositoryRootURL: tempRepository.tempRoot) == baselineCount + 2) + + // Switching back to first worktree also triggers immediate refresh (different worktree). + manager.handleCommand(.setSelectedWorktreeID(firstWorktree.id)) + await drainAsyncEvents() + #expect(await collector.pullRequestRefreshCount(repositoryRootURL: tempRepository.tempRoot) == baselineCount + 3) + + manager.handleCommand(.stop) + await task.value + try FileManager.default.removeItem(at: tempRepository.tempRoot) + } + + @Test func reselectingSameWorktreeRespectsCooldown() async throws { + let clock = TestClock() + let tempRepository = try makeTempRepository(worktreeNames: ["sparrow", "swift"]) + let manager = WorktreeInfoWatcherManager( + focusedInterval: .seconds(3_600), + unfocusedInterval: .seconds(3_600), + pullRequestSelectionRefreshCooldown: .milliseconds(500), + clock: clock + ) + let (collector, task) = startCollecting(manager.eventStream()) + + manager.handleCommand(.setWorktrees(tempRepository.worktrees)) + await drainAsyncEvents() + let baselineCount = await collector.pullRequestRefreshCount(repositoryRootURL: tempRepository.tempRoot) + #expect(baselineCount == 1) + let firstWorktree = try #require(tempRepository.worktrees.first) await clock.advance(by: .milliseconds(500)) await drainAsyncEvents() + // Selecting worktree triggers immediate refresh and starts cooldown. + manager.handleCommand(.setSelectedWorktreeID(firstWorktree.id)) + await drainAsyncEvents() + #expect(await collector.pullRequestRefreshCount(repositoryRootURL: tempRepository.tempRoot) == baselineCount + 1) + + // Deselect then re-select the same worktree within cooldown — should NOT refresh. + manager.handleCommand(.setSelectedWorktreeID(nil)) + await drainAsyncEvents() + manager.handleCommand(.setSelectedWorktreeID(firstWorktree.id)) + await drainAsyncEvents() + #expect(await collector.pullRequestRefreshCount(repositoryRootURL: tempRepository.tempRoot) == baselineCount + 1) + + // After cooldown expires, re-selecting the same worktree should refresh. + await clock.advance(by: .milliseconds(500)) + await drainAsyncEvents() + manager.handleCommand(.setSelectedWorktreeID(nil)) + await drainAsyncEvents() manager.handleCommand(.setSelectedWorktreeID(firstWorktree.id)) await drainAsyncEvents() #expect(await collector.pullRequestRefreshCount(repositoryRootURL: tempRepository.tempRoot) == baselineCount + 2) @@ -433,11 +479,12 @@ struct WorktreeInfoWatcherManagerTests { ) #expect(afterReplacementCooldownCount == afterFirstSelectionCount + 2) + // Switching back to first worktree triggers refresh (different worktree). manager.handleCommand(.setSelectedWorktreeID(firstWorktree.id)) await drainAsyncEvents() #expect( await collector.pullRequestRefreshCount(repositoryRootURL: tempRepository.tempRoot) - == afterReplacementCooldownCount + == afterReplacementCooldownCount + 1 ) manager.handleCommand(.stop) -- 2.51.2 From 72555347e4ebf52be55546aa74d46a9dfc8a6d85 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 24 Jun 2026 16:49:46 +0800 Subject: [PATCH 2/3] Extract handlePullRequestRefreshOnSelection to reduce duplication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidate the cooldown-cancellation and PR-refresh scheduling logic that was duplicated across the same-repo and cross-repo branches of setSelectedWorktreeID into a single helper method. This also fixes the comment scope — the original comment only mentioned "within the same repo" but the same logic applies to entering a new repo. Signed-off-by: Alex --- .../WorktreeInfoWatcherManager.swift | 47 +++++++++---------- 1 file changed, 21 insertions(+), 26 deletions(-) diff --git a/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift b/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift index a78e228b..f7a80a25 100644 --- a/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift +++ b/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift @@ -221,39 +221,17 @@ final class WorktreeInfoWatcherManager { emitLineChangesChanged(worktreeID: worktreeID) syncLineChangesActivity(for: worktreeID) } - // When switching to a different worktree within the same repo, cancel any active cooldown - // so the PR refresh fires immediately. Re-selecting the same worktree still respects the cooldown. + // When switching to a different worktree, cancel any active cooldown so the PR refresh + // fires immediately. Re-selecting the same worktree still respects the cooldown. if let previousRepository, previousRepository == nextRepository { - let lastWorktreeForRepo = lastSelectedWorktreeIDByRepo[previousRepository] - let isDifferentWorktree = lastWorktreeForRepo != worktreeID - if isDifferentWorktree { - cancelPullRequestSelectionCooldown(for: previousRepository) - } - updatePullRequestSchedule( - repositoryRootURL: previousRepository, - immediate: shouldImmediatelyRefreshPullRequests(repositoryRootURL: previousRepository) - ) - if let worktreeID { - lastSelectedWorktreeIDByRepo[previousRepository] = worktreeID - } + handlePullRequestRefreshOnSelection(repositoryRootURL: previousRepository, worktreeID: worktreeID) return } if let previousRepository { updatePullRequestSchedule(repositoryRootURL: previousRepository, immediate: false) } if let nextRepository { - let lastWorktreeForRepo = lastSelectedWorktreeIDByRepo[nextRepository] - let isDifferentWorktree = lastWorktreeForRepo != worktreeID - if isDifferentWorktree { - cancelPullRequestSelectionCooldown(for: nextRepository) - } - updatePullRequestSchedule( - repositoryRootURL: nextRepository, - immediate: shouldImmediatelyRefreshPullRequests(repositoryRootURL: nextRepository) - ) - if let worktreeID { - lastSelectedWorktreeIDByRepo[nextRepository] = worktreeID - } + handlePullRequestRefreshOnSelection(repositoryRootURL: nextRepository, worktreeID: worktreeID) } } @@ -803,6 +781,23 @@ final class WorktreeInfoWatcherManager { eventContinuation?.yield(event) } + private func handlePullRequestRefreshOnSelection( + repositoryRootURL: URL, + worktreeID: Worktree.ID? + ) { + let lastWorktreeForRepo = lastSelectedWorktreeIDByRepo[repositoryRootURL] + if lastWorktreeForRepo != worktreeID { + cancelPullRequestSelectionCooldown(for: repositoryRootURL) + } + updatePullRequestSchedule( + repositoryRootURL: repositoryRootURL, + immediate: shouldImmediatelyRefreshPullRequests(repositoryRootURL: repositoryRootURL) + ) + if let worktreeID { + lastSelectedWorktreeIDByRepo[repositoryRootURL] = worktreeID + } + } + private func cancelPullRequestSelectionCooldown(for repositoryRootURL: URL) { pullRequestSelectionCooldownTasksByRepo.removeValue(forKey: repositoryRootURL)?.task.cancel() } -- 2.51.2 From 8a8300edbc0336b75a1348b7c0ef123fad4345d6 Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 24 Jun 2026 21:12:38 +0900 Subject: [PATCH 3/3] Prune lastSelectedWorktreeIDByRepo for obsolete repositories Builds on #499. setWorktrees already cancels cooldown tasks for repositories that are no longer present, but the new lastSelectedWorktreeIDByRepo dictionary was only cleared wholesale in stopAll(), leaving stale URL->ID entries for removed repositories. Mirror the adjacent obsolete-cooldown cleanup so the tracking dictionary stays bounded to live repositories. Hygiene only: because the obsolete repo's cooldown is already cancelled on removal, a lingering entry could not change refresh behavior, so there is no observable behavior change to assert in a test. --- .../BusinessLogic/WorktreeInfoWatcherManager.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift b/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift index f7a80a25..431664a4 100644 --- a/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift +++ b/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift @@ -192,6 +192,12 @@ final class WorktreeInfoWatcherManager { for repositoryRootURL in obsoleteCooldownRepositories { cancelPullRequestSelectionCooldown(for: repositoryRootURL) } + let obsoleteSelectionRepositories = lastSelectedWorktreeIDByRepo.keys.filter { + !repositoryRoots.contains($0) + } + for repositoryRootURL in obsoleteSelectionRepositories { + lastSelectedWorktreeIDByRepo.removeValue(forKey: repositoryRootURL) + } } private func setOpenedWorktreeIDs(_ worktreeIDs: Set) {