diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 10ced5dc..c60a20f9 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -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] = [] diff --git a/cmuxTests/TabManagerUnitTests.swift b/cmuxTests/TabManagerUnitTests.swift index d6968187..bed4b0cb 100644 --- a/cmuxTests/TabManagerUnitTests.swift +++ b/cmuxTests/TabManagerUnitTests.swift @@ -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(