Fix sidebar live refresh for branch and PR state (#2331)

* Add regression coverage for sidebar live refresh

* Refresh sidebar git metadata on active workspaces
This commit is contained in:
Austin Wang 2026-03-29 18:15:57 -07:00 committed by GitHub
parent e419fd9164
commit 94cc865e83
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 93 additions and 24 deletions

View file

@ -699,6 +699,7 @@ class TabManager: ObservableObject {
private static var nextPortOrdinal: Int = 0
private static let initialWorkspaceGitProbeDelays: [TimeInterval] = [0, 0.5, 1.5, 3.0, 6.0, 10.0]
private static let workspaceGitMetadataPollInterval: TimeInterval = 30
private static let selectedWorkspaceGitMetadataPollInterval: TimeInterval = 5
private nonisolated static let workspacePullRequestProbeTimeout: TimeInterval = 5.0
@Published var selectedTabId: UUID? {
willSet {
@ -820,6 +821,7 @@ class TabManager: ObservableObject {
}
private var agentPIDSweepTimer: DispatchSourceTimer?
private var workspaceGitMetadataPollTimer: DispatchSourceTimer?
private var selectedWorkspaceGitMetadataPollTimer: DispatchSourceTimer?
#if DEBUG
private var debugWorkspaceSwitchCounter: UInt64 = 0
private var debugWorkspaceSwitchId: UInt64 = 0
@ -867,6 +869,7 @@ class TabManager: ObservableObject {
startAgentPIDSweepTimer()
startWorkspaceGitMetadataPollTimer()
startSelectedWorkspaceGitMetadataPollTimer()
#if DEBUG
setupUITestFocusShortcutsIfNeeded()
setupSplitCloseRightUITestIfNeeded()
@ -879,6 +882,7 @@ class TabManager: ObservableObject {
workspaceCycleCooldownTask?.cancel()
agentPIDSweepTimer?.cancel()
workspaceGitMetadataPollTimer?.cancel()
selectedWorkspaceGitMetadataPollTimer?.cancel()
}
// MARK: - Agent PID Sweep
@ -918,6 +922,24 @@ class TabManager: ObservableObject {
workspaceGitMetadataPollTimer = timer
}
/// Refresh the selected workspace more aggressively so branch checkouts and
/// newly created PRs show up in the sidebar without waiting for the slower
/// background sweep across every tracked workspace.
private func startSelectedWorkspaceGitMetadataPollTimer() {
let timer = DispatchSource.makeTimerSource(queue: .global(qos: .utility))
let interval = Self.selectedWorkspaceGitMetadataPollInterval
timer.schedule(deadline: .now() + interval, repeating: interval)
timer.setEventHandler { [weak self] in
guard let self else { return }
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.refreshSelectedWorkspaceGitMetadata()
}
}
timer.resume()
selectedWorkspaceGitMetadataPollTimer = timer
}
private func refreshTrackedWorkspaceGitMetadata() {
let activeProbeKeys = Set(workspaceGitProbeGenerationByKey.keys)
@ -935,6 +957,26 @@ class TabManager: ObservableObject {
}
}
private func refreshSelectedWorkspaceGitMetadata() {
guard let workspace = selectedWorkspace,
let focusedPanelId = workspace.focusedPanelId else {
return
}
let activeProbeKeys = Set(workspaceGitProbeGenerationByKey.keys)
let candidatePanelIds = trackedWorkspaceGitMetadataPollCandidatePanelIds(
in: workspace,
activeProbeKeys: activeProbeKeys
)
guard candidatePanelIds.contains(focusedPanelId) else { return }
scheduleWorkspaceGitMetadataRefreshIfPossible(
workspaceId: workspace.id,
panelId: focusedPanelId,
reason: "selectedPeriodicPoll"
)
}
func refreshTrackedWorkspaceGitMetadataForTesting() {
refreshTrackedWorkspaceGitMetadata()
}
@ -965,28 +1007,10 @@ class TabManager: ObservableObject {
return Set(candidatePanelIds.filter { panelId in
let probeKey = WorkspaceGitProbeKey(workspaceId: workspace.id, panelId: panelId)
guard !activeProbeKeys.contains(probeKey) else { return false }
return shouldPollTrackedWorkspaceGitMetadata(in: workspace, panelId: panelId)
return !activeProbeKeys.contains(probeKey)
})
}
private func shouldPollTrackedWorkspaceGitMetadata(in workspace: Workspace, panelId: UUID) -> Bool {
guard let branch = trackedWorkspaceGitBranch(in: workspace, panelId: panelId) else {
return true
}
return !Self.shouldSkipWorkspacePullRequestLookup(branch: branch)
}
private func trackedWorkspaceGitBranch(in workspace: Workspace, panelId: UUID) -> String? {
if let branch = workspace.panelGitBranches[panelId]?.branch {
return branch
}
if workspace.focusedPanelId == panelId {
return workspace.gitBranch?.branch
}
return nil
}
private func sweepStaleAgentPIDs() {
for tab in tabs {
var keysToRemove: [String] = []

View file

@ -404,7 +404,7 @@ final class TabManagerPullRequestProbeTests: XCTestCase {
XCTAssertFalse(TabManager.shouldSkipWorkspacePullRequestLookup(branch: "release/master-fix"))
}
func testTrackedWorkspaceGitMetadataPollCandidatesSkipMainAndMasterPanelsOnly() throws {
func testTrackedWorkspaceGitMetadataPollCandidatesIncludeMainAndMasterPanels() throws {
let manager = TabManager()
guard let workspace = manager.selectedWorkspace,
let mainPanelId = workspace.focusedPanelId else {
@ -435,11 +435,11 @@ final class TabManagerPullRequestProbeTests: XCTestCase {
XCTAssertEqual(
manager.trackedWorkspaceGitMetadataPollCandidatePanelIdsForTesting(workspaceId: workspace.id),
Set([featurePanel.id, mainlinePanel.id])
Set([mainPanelId, masterPanel.id, featurePanel.id, mainlinePanel.id])
)
}
func testTrackedWorkspaceGitMetadataPollCandidatesSkipFocusedFallbackOnMainOnly() {
func testTrackedWorkspaceGitMetadataPollCandidatesIncludeFocusedFallbackOnMain() {
let manager = TabManager()
guard let workspace = manager.selectedWorkspace,
let panelId = workspace.focusedPanelId else {
@ -448,8 +448,9 @@ final class TabManagerPullRequestProbeTests: XCTestCase {
}
workspace.gitBranch = SidebarGitBranchState(branch: "main", isDirty: false)
XCTAssertTrue(
manager.trackedWorkspaceGitMetadataPollCandidatePanelIdsForTesting(workspaceId: workspace.id).isEmpty
XCTAssertEqual(
manager.trackedWorkspaceGitMetadataPollCandidatePanelIdsForTesting(workspaceId: workspace.id),
Set([panelId])
)
workspace.gitBranch = SidebarGitBranchState(branch: "feature/sidebar-pr", isDirty: false)
@ -459,6 +460,50 @@ final class TabManagerPullRequestProbeTests: XCTestCase {
)
}
func testPeriodicWorkspaceGitMetadataRefreshUpdatesMainWorkspaceAfterCheckoutToFeatureBranch() throws {
let fileManager = FileManager.default
let repoURL = fileManager.temporaryDirectory.appendingPathComponent("cmux-git-main-refresh-\(UUID().uuidString)")
try fileManager.createDirectory(at: repoURL, withIntermediateDirectories: true)
defer { try? fileManager.removeItem(at: repoURL) }
try runGit(["init", "-b", "main"], in: repoURL)
try runGit(["config", "user.name", "cmux tests"], in: repoURL)
try runGit(["config", "user.email", "cmux@example.invalid"], in: repoURL)
try "seed\n".write(
to: repoURL.appendingPathComponent("README.md"),
atomically: true,
encoding: .utf8
)
try runGit(["add", "README.md"], in: repoURL)
try runGit(["commit", "-m", "Initial commit"], in: repoURL)
let manager = TabManager()
guard let workspace = manager.selectedWorkspace,
let panelId = workspace.focusedPanelId else {
XCTFail("Expected selected workspace with focused panel")
return
}
workspace.updatePanelDirectory(panelId: panelId, directory: repoURL.path)
workspace.updatePanelGitBranch(panelId: panelId, branch: "main", isDirty: false)
XCTAssertEqual(
manager.trackedWorkspaceGitMetadataPollCandidatePanelIdsForTesting(workspaceId: workspace.id),
Set([panelId])
)
try runGit(["checkout", "-b", "feature/sidebar-live-refresh"], in: repoURL)
manager.refreshTrackedWorkspaceGitMetadataForTesting()
XCTAssertTrue(
waitForCondition {
workspace.panelGitBranches[panelId]?.branch == "feature/sidebar-live-refresh"
}
)
XCTAssertEqual(workspace.gitBranch?.branch, "feature/sidebar-live-refresh")
}
func testResolvedCommandPathFallsBackOutsideAppPATH() throws {
let fileManager = FileManager.default
let tempDir = fileManager.temporaryDirectory.appendingPathComponent(