diff --git a/Resources/shell-integration/cmux-bash-integration.bash b/Resources/shell-integration/cmux-bash-integration.bash index 75b116dca138..75b80bd0c07d 100644 --- a/Resources/shell-integration/cmux-bash-integration.bash +++ b/Resources/shell-integration/cmux-bash-integration.bash @@ -534,6 +534,7 @@ _cmux_record_pr_command_hint() { } _cmux_emit_pr_command_hint() { + [[ "${CMUX_NO_PR_WATCH:-}" == "1" ]] && return 0 [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 [[ -n "$CMUX_TAB_ID" ]] || return 0 [[ -n "$CMUX_PANEL_ID" ]] || return 0 @@ -867,6 +868,11 @@ _cmux_pr_request_probe() { _cmux_report_pr_for_path() { local repo_path="$1" local force_probe="${2:-0}" + if [[ "${CMUX_NO_PR_WATCH:-}" == "1" ]]; then + _cmux_pr_cache_clear + _cmux_clear_pr_for_panel + return 0 + fi [[ -n "$repo_path" ]] || { _cmux_pr_cache_clear _cmux_clear_pr_for_panel @@ -1070,6 +1076,10 @@ _cmux_stop_pr_poll_loop() { } _cmux_start_pr_poll_loop() { + if [[ "${CMUX_NO_PR_WATCH:-}" == "1" ]]; then + _cmux_stop_pr_poll_loop + return 0 + fi [[ "${CMUX_NO_GIT_WATCH:-}" == "1" ]] && return 0 [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 [[ -n "$CMUX_TAB_ID" ]] || return 0 diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh index 6cd9dd4ac0ee..4406ce5ec781 100644 --- a/Resources/shell-integration/cmux-zsh-integration.zsh +++ b/Resources/shell-integration/cmux-zsh-integration.zsh @@ -662,6 +662,7 @@ _cmux_record_pr_command_hint() { } _cmux_emit_pr_command_hint() { + [[ "${CMUX_NO_PR_WATCH:-}" == "1" ]] && return 0 [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 [[ -n "$CMUX_TAB_ID" ]] || return 0 [[ -n "$CMUX_PANEL_ID" ]] || return 0 @@ -999,6 +1000,11 @@ _cmux_pr_request_probe() { _cmux_report_pr_for_path() { local repo_path="$1" local force_probe="${2:-0}" + if [[ "${CMUX_NO_PR_WATCH:-}" == "1" ]]; then + _cmux_pr_cache_clear + _cmux_clear_pr_for_panel + return 0 + fi [[ -n "$repo_path" ]] || { _cmux_pr_cache_clear _cmux_clear_pr_for_panel @@ -1202,6 +1208,10 @@ _cmux_stop_pr_poll_loop() { } _cmux_start_pr_poll_loop() { + if [[ "${CMUX_NO_PR_WATCH:-}" == "1" ]]; then + _cmux_stop_pr_poll_loop + return 0 + fi [[ "${CMUX_NO_GIT_WATCH:-}" == "1" ]] && return 0 [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 [[ -n "$CMUX_TAB_ID" ]] || return 0 diff --git a/Sources/CmuxSettingsJSONPathSupport.swift b/Sources/CmuxSettingsJSONPathSupport.swift index df2c8e1c0602..a19cd8ac3c1d 100644 --- a/Sources/CmuxSettingsJSONPathSupport.swift +++ b/Sources/CmuxSettingsJSONPathSupport.swift @@ -35,6 +35,10 @@ extension SidebarWorkspaceDetailDefaults { static func watchGitStatusValue(defaults: UserDefaults) -> Bool { boolValue(defaults: defaults, key: watchGitStatusKey, defaultValue: watchGitStatus) } + + static func pullRequestPollingEnabled(defaults: UserDefaults) -> Bool { + watchGitStatusValue(defaults: defaults) && showPullRequestsValue(defaults: defaults) + } } enum AutomationSettings { diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 62347f28b28f..18ac01b5326e 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -6158,6 +6158,7 @@ final class TerminalSurface: Identifiable, ObservableObject { setManagedEnvironmentValue("CMUX_SHELL_INTEGRATION_DIR", integrationDir) Self.applyManagedGitWatchEnvironment( watchGitStatusEnabled: SidebarWorkspaceDetailDefaults.watchGitStatusValue(defaults: .standard), + showPullRequestsEnabled: SidebarWorkspaceDetailDefaults.showPullRequestsValue(defaults: .standard), to: &env, protectedKeys: &protectedStartupEnvironmentKeys ) diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 42543ea1339d..266ef56f6969 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -1253,6 +1253,7 @@ class TabManager: ObservableObject { private var workspaceGitMetadataWatcherDescriptorGeneration: UInt64 = 0 private var workspaceGitMetadataFallbackTimer: DispatchSourceTimer? private var lastSidebarGitMetadataWatchEnabled = SidebarWorkspaceDetailDefaults.watchGitStatusValue(defaults: .standard) + private var lastSidebarPullRequestPollingEnabled = SidebarWorkspaceDetailDefaults.pullRequestPollingEnabled(defaults: .standard) private var workspacePullRequestProbeStateByKey: [WorkspaceGitProbeKey: WorkspaceGitProbeState] = [:] private var workspacePullRequestNextPollAtByKey: [WorkspaceGitProbeKey: Date] = [:] private var workspacePullRequestLastTerminalStateRefreshAtByKey: [WorkspaceGitProbeKey: Date] = [:] @@ -1342,7 +1343,7 @@ class TabManager: ObservableObject { queue: .main ) { [weak self] _ in MainActor.assumeIsolated { [weak self] in - self?.sidebarGitMetadataWatchSettingsDidChange() + self?.sidebarMetadataSettingsDidChange() self?.refreshTabCloseButtonVisibility() } }) @@ -1382,7 +1383,7 @@ class TabManager: ObservableObject { } private func updateWorkspacePullRequestPollTimer() { - guard sidebarGitMetadataWatchEnabled else { + guard sidebarPullRequestPollingEnabled else { workspacePullRequestPollTimer?.cancel() workspacePullRequestPollTimer = nil return @@ -1468,6 +1469,15 @@ class TabManager: ObservableObject { SidebarWorkspaceDetailDefaults.watchGitStatusValue(defaults: .standard) } + private var sidebarPullRequestPollingEnabled: Bool { + SidebarWorkspaceDetailDefaults.pullRequestPollingEnabled(defaults: .standard) + } + + private func sidebarMetadataSettingsDidChange() { + sidebarGitMetadataWatchSettingsDidChange() + sidebarPullRequestPollingSettingsDidChange() + } + private func sidebarGitMetadataWatchSettingsDidChange() { let isEnabled = sidebarGitMetadataWatchEnabled guard isEnabled != lastSidebarGitMetadataWatchEnabled else { @@ -1500,6 +1510,22 @@ class TabManager: ObservableObject { updateWorkspaceGitMetadataFallbackTimer() } + private func sidebarPullRequestPollingSettingsDidChange() { + let isEnabled = sidebarPullRequestPollingEnabled + guard isEnabled != lastSidebarPullRequestPollingEnabled else { + return + } + lastSidebarPullRequestPollingEnabled = isEnabled + + guard isEnabled else { + resetWorkspacePullRequestRefreshState() + clearAllWorkspaceSidebarPullRequestMetadata() + return + } + + refreshTrackedWorkspacePullRequestsIfNeeded(reason: "pullRequestVisibilityEnabled") + } + private func restartWorkspaceGitMetadataWatching(reason: String) { for workspace in tabs where !workspace.isRemoteWorkspace { for panelId in workspace.panels.keys { @@ -1627,9 +1653,9 @@ class TabManager: ObservableObject { reason: String, allowCachedResultsOverride: Bool? = nil ) { - guard sidebarGitMetadataWatchEnabled else { + guard sidebarPullRequestPollingEnabled else { resetWorkspacePullRequestRefreshState() - clearAllWorkspaceSidebarGitMetadata() + clearAllWorkspaceSidebarPullRequestMetadata() return } @@ -1815,8 +1841,8 @@ class TabManager: ObservableObject { reason: String ) { let key = WorkspaceGitProbeKey(workspaceId: workspaceId, panelId: panelId) - guard sidebarGitMetadataWatchEnabled else { - clearWorkspaceGitMetadata(for: key) + guard sidebarPullRequestPollingEnabled else { + clearWorkspacePullRequestMetadata(for: key) return } let shouldBypassRepoCache = !Self.workspacePullRequestRefreshAllowsRepoCache(reason: reason) @@ -1847,6 +1873,12 @@ class TabManager: ObservableObject { now: Date, reason: String ) { + guard sidebarPullRequestPollingEnabled else { + resetWorkspacePullRequestRefreshState() + clearAllWorkspaceSidebarPullRequestMetadata() + return + } + for (repoSlug, repoResult) in repoResults { guard case .success(let cacheEntry, let usedCache, _) = repoResult, !usedCache else { @@ -2049,6 +2081,14 @@ class TabManager: ObservableObject { updateWorkspacePullRequestPollTimer() } + private func clearWorkspacePullRequestMetadata(for key: WorkspaceGitProbeKey) { + clearWorkspacePullRequestTracking(for: key) + guard let workspace = tabs.first(where: { $0.id == key.workspaceId }) else { + return + } + workspace.clearPanelPullRequest(panelId: key.panelId) + } + private func resetWorkspacePullRequestRefreshState() { workspacePullRequestRefreshTask?.cancel() workspacePullRequestRefreshTask = nil @@ -2152,7 +2192,7 @@ class TabManager: ObservableObject { } func sidebarGitMetadataWatchSettingsDidChangeForTesting() { - sidebarGitMetadataWatchSettingsDidChange() + sidebarMetadataSettingsDidChange() } func trackedWorkspaceGitMetadataPollCandidatePanelIdsForTesting(workspaceId: UUID) -> Set { @@ -2172,6 +2212,14 @@ class TabManager: ObservableObject { return Set(probeKeys.map(\.panelId)) } + func workspacePullRequestTrackedPanelIdsForTesting(workspaceId: UUID) -> Set { + let probeKeys = Set(workspacePullRequestProbeStateByKey.keys.filter { $0.workspaceId == workspaceId }) + .union(workspacePullRequestNextPollAtByKey.keys.filter { $0.workspaceId == workspaceId }) + .union(workspacePullRequestLastTerminalStateRefreshAtByKey.keys.filter { $0.workspaceId == workspaceId }) + .union(workspacePullRequestTransientFailureCountByKey.keys.filter { $0.workspaceId == workspaceId }) + return Set(probeKeys.map(\.panelId)) + } + private func trackedWorkspaceGitMetadataPollCandidatePanelIds( in workspace: Workspace, activeProbeKeys: Set @@ -2827,6 +2875,12 @@ class TabManager: ObservableObject { } } + private func clearAllWorkspaceSidebarPullRequestMetadata() { + for workspace in tabs { + workspace.clearSidebarPullRequestMetadata() + } + } + private func clearWorkspaceGitProbes(workspaceId: UUID) { let keys = Set(workspaceGitProbeStateByKey.keys.filter { $0.workspaceId == workspaceId }) .union(workspaceGitProbeTimersByKey.keys.filter { $0.workspaceId == workspaceId }) @@ -2860,7 +2914,9 @@ class TabManager: ObservableObject { if case .inFlight = workspaceGitProbeStateByKey[probeKey] { return true } return false }() + let shouldTrackPullRequests = sidebarPullRequestPollingEnabled let resolvedPullRequest: SidebarPullRequestState? = { + guard shouldTrackPullRequests else { return nil } guard case .resolved(let pullRequest) = snapshot.pullRequest else { return nil } return pullRequest }() @@ -2983,24 +3039,31 @@ class TabManager: ObservableObject { switch snapshot.pullRequest { case .resolved(let pullRequest): - workspace.updatePanelPullRequest( - panelId: probeKey.panelId, - number: pullRequest.number, - label: pullRequest.label, - url: pullRequest.url, - status: pullRequest.status, - branch: pullRequest.branch, - isStale: false - ) + if shouldTrackPullRequests { + workspace.updatePanelPullRequest( + panelId: probeKey.panelId, + number: pullRequest.number, + label: pullRequest.label, + url: pullRequest.url, + status: pullRequest.status, + branch: pullRequest.branch, + isStale: false + ) + } else if workspace.panelPullRequests[probeKey.panelId] != nil { + workspace.clearPanelPullRequest(panelId: probeKey.panelId) + } case .notFound: if workspace.panelPullRequests[probeKey.panelId] != nil { workspace.clearPanelPullRequest(panelId: probeKey.panelId) } case .deferred, .unsupportedRepository, .transientFailure: + if !shouldTrackPullRequests, workspace.panelPullRequests[probeKey.panelId] != nil { + workspace.clearPanelPullRequest(panelId: probeKey.panelId) + } break } - if snapshot.branch != nil { + if snapshot.branch != nil, shouldTrackPullRequests { scheduleWorkspacePullRequestRefresh( workspaceId: probeKey.workspaceId, panelId: probeKey.panelId, @@ -5716,6 +5779,10 @@ class TabManager: ObservableObject { target: String? ) { guard let tab = tabs.first(where: { $0.id == tabId }) else { return } + guard sidebarPullRequestPollingEnabled else { + clearWorkspacePullRequestMetadata(for: WorkspaceGitProbeKey(workspaceId: tabId, panelId: surfaceId)) + return + } reconcileLocalPullRequestActionIfPossible( workspace: tab, panelId: surfaceId, diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 17c5d98be24b..d29ed186f4ac 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -19687,7 +19687,7 @@ class TerminalController { options: parsed.options, missingPanelUsage: "report_pr [--label=PR] [--state=open|merged|closed] [--branch=] [--tab=X] [--panel=Y]" ) { tab, surfaceId in - guard SidebarWorkspaceDetailDefaults.watchGitStatusValue(defaults: .standard) else { + guard SidebarWorkspaceDetailDefaults.pullRequestPollingEnabled(defaults: .standard) else { tab.clearPanelPullRequest(panelId: surfaceId) return } @@ -19924,7 +19924,7 @@ class TerminalController { options: parsed.options, missingPanelUsage: "report_pr_action [--target=X] [--tab=X] [--panel=Y]" ) { tab, surfaceId in - guard SidebarWorkspaceDetailDefaults.watchGitStatusValue(defaults: .standard) else { + guard SidebarWorkspaceDetailDefaults.pullRequestPollingEnabled(defaults: .standard) else { tab.clearPanelPullRequest(panelId: surfaceId) return } diff --git a/Sources/TerminalStartupEnvironment.swift b/Sources/TerminalStartupEnvironment.swift index b0e5fda551a9..b1fcc5f468f4 100644 --- a/Sources/TerminalStartupEnvironment.swift +++ b/Sources/TerminalStartupEnvironment.swift @@ -53,11 +53,14 @@ extension TerminalSurface { static func applyManagedGitWatchEnvironment( watchGitStatusEnabled: Bool, + showPullRequestsEnabled: Bool = true, to environment: inout [String: String], protectedKeys: inout Set ) { environment["CMUX_NO_GIT_WATCH"] = watchGitStatusEnabled ? "" : "1" protectedKeys.insert("CMUX_NO_GIT_WATCH") + environment["CMUX_NO_PR_WATCH"] = (watchGitStatusEnabled && showPullRequestsEnabled) ? "" : "1" + protectedKeys.insert("CMUX_NO_PR_WATCH") } static func mergedStartupEnvironment( diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 322856d16b6f..c185aea8f853 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -10936,19 +10936,23 @@ final class Workspace: Identifiable, ObservableObject { } } + func clearSidebarPullRequestMetadata() { + if !panelPullRequests.isEmpty { + panelPullRequests.removeAll() + } + if pullRequest != nil { + pullRequest = nil + } + } + func clearSidebarGitMetadata() { if !panelGitBranches.isEmpty { panelGitBranches.removeAll() } - if !panelPullRequests.isEmpty { - panelPullRequests.removeAll() - } + clearSidebarPullRequestMetadata() if gitBranch != nil { gitBranch = nil } - if pullRequest != nil { - pullRequest = nil - } } func resetSidebarContext(reason: String = "unspecified") { diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index af735db3ced7..a854172aa5f6 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -5035,6 +5035,106 @@ final class ZshShellIntegrationHandoffTests: XCTestCase { XCTAssertFalse(output.contains("report_pr_action"), output) } + func testZshNoPullRequestWatchSkipsLegacyGhPRProbe() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-zsh-no-pr-watch-\(UUID().uuidString)") + let repoURL = root.appendingPathComponent("repo", isDirectory: true) + let fakeBinURL = root.appendingPathComponent("fake-bin", isDirectory: true) + let markerURL = root.appendingPathComponent("gh-pr-invoked", isDirectory: false) + let socketPath = root.appendingPathComponent("cmux-test.sock", isDirectory: false) + + try fileManager.createDirectory(at: repoURL.appendingPathComponent(".git"), withIntermediateDirectories: true) + try fileManager.createDirectory(at: fakeBinURL, withIntermediateDirectories: true) + try "ref: refs/heads/issue-2746-rate-limit\n".write( + to: repoURL.appendingPathComponent(".git/HEAD"), + atomically: true, + encoding: .utf8 + ) + try writeExecutableScript( + at: fakeBinURL.appendingPathComponent("gh"), + contents: """ + #!/bin/sh + printf invoked > "$CMUX_GH_MARKER" + printf '2746\\tOPEN\\thttps://github.com/manaflow-ai/cmux/pull/2746\\n' + """ + ) + let socketFD = try bindUnixSocket(at: socketPath.path) + defer { + Darwin.close(socketFD) + unlink(socketPath.path) + try? fileManager.removeItem(at: root) + } + + let output = try runInteractiveZsh( + cmuxLoadGhosttyIntegration: false, + cmuxLoadShellIntegration: true, + command: """ + _cmux_send() { :; } + _cmux_send_bg() { :; } + _cmux_report_pr_for_path "\(repoURL.path)" || true + [[ -e "\(markerURL.path)" ]] && print MARKER=1 || print MARKER=0 + """, + extraEnvironment: [ + "CMUX_NO_PR_WATCH": "1", + "CMUX_GH_MARKER": markerURL.path, + "CMUX_SOCKET_PATH": socketPath.path, + "PATH": "\(fakeBinURL.path):/usr/bin:/bin", + ] + ) + + XCTAssertTrue(output.contains("MARKER=0"), output) + } + + func testBashNoPullRequestWatchSkipsLegacyGhPRProbe() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-bash-no-pr-watch-\(UUID().uuidString)") + let repoURL = root.appendingPathComponent("repo", isDirectory: true) + let fakeBinURL = root.appendingPathComponent("fake-bin", isDirectory: true) + let markerURL = root.appendingPathComponent("gh-pr-invoked", isDirectory: false) + let socketPath = root.appendingPathComponent("cmux-test.sock", isDirectory: false) + + try fileManager.createDirectory(at: repoURL.appendingPathComponent(".git"), withIntermediateDirectories: true) + try fileManager.createDirectory(at: fakeBinURL, withIntermediateDirectories: true) + try "ref: refs/heads/issue-2746-rate-limit\n".write( + to: repoURL.appendingPathComponent(".git/HEAD"), + atomically: true, + encoding: .utf8 + ) + try writeExecutableScript( + at: fakeBinURL.appendingPathComponent("gh"), + contents: """ + #!/bin/sh + printf invoked > "$CMUX_GH_MARKER" + printf '2746\\tOPEN\\thttps://github.com/manaflow-ai/cmux/pull/2746\\n' + """ + ) + let socketFD = try bindUnixSocket(at: socketPath.path) + defer { + Darwin.close(socketFD) + unlink(socketPath.path) + try? fileManager.removeItem(at: root) + } + + let result = try runInteractiveBash( + cmuxLoadShellIntegration: true, + command: """ + _cmux_send() { :; } + _cmux_report_pr_for_path "\(repoURL.path)" || true + [[ -e "\(markerURL.path)" ]] && printf 'MARKER=1\\n' || printf 'MARKER=0\\n' + """, + extraEnvironment: [ + "CMUX_NO_PR_WATCH": "1", + "CMUX_GH_MARKER": markerURL.path, + "CMUX_SOCKET_PATH": socketPath.path, + "PATH": "\(fakeBinURL.path):/usr/bin:/bin", + ] + ) + + XCTAssertTrue(result.stdout.contains("MARKER=0"), result.stdout) + } + func testZshPromptResetsTerminalKeyboardProtocols() throws { let output = try runInteractiveZsh( cmuxLoadGhosttyIntegration: false, diff --git a/cmuxTests/GhosttyTerminalStartupEnvironmentTests.swift b/cmuxTests/GhosttyTerminalStartupEnvironmentTests.swift index b40d1435dcb9..64189f0dda9e 100644 --- a/cmuxTests/GhosttyTerminalStartupEnvironmentTests.swift +++ b/cmuxTests/GhosttyTerminalStartupEnvironmentTests.swift @@ -99,6 +99,25 @@ final class GhosttyTerminalStartupEnvironmentTests: XCTestCase { XCTAssertEqual(merged["CMUX_NO_GIT_WATCH"], "") } + func testApplyManagedGitWatchEnvironmentDisablesShellPullRequestWatchWhenHidden() { + var environment = [ + "CMUX_NO_PR_WATCH": "" + ] + var protectedKeys: Set = [] + + TerminalSurface.applyManagedGitWatchEnvironment( + watchGitStatusEnabled: true, + showPullRequestsEnabled: false, + to: &environment, + protectedKeys: &protectedKeys + ) + + XCTAssertEqual(environment["CMUX_NO_GIT_WATCH"], "") + XCTAssertEqual(environment["CMUX_NO_PR_WATCH"], "1") + XCTAssertTrue(protectedKeys.contains("CMUX_NO_GIT_WATCH")) + XCTAssertTrue(protectedKeys.contains("CMUX_NO_PR_WATCH")) + } + func testMergedStartupEnvironmentAllowsSessionReplayAndInitialEnvCMUXKeys() { let replayPath = "/tmp/cmux-replay-\(UUID().uuidString)" let merged = TerminalSurface.mergedStartupEnvironment( diff --git a/cmuxTests/WorkspacePullRequestSidebarTests.swift b/cmuxTests/WorkspacePullRequestSidebarTests.swift index 13b2b0317cb2..16eb184ab427 100644 --- a/cmuxTests/WorkspacePullRequestSidebarTests.swift +++ b/cmuxTests/WorkspacePullRequestSidebarTests.swift @@ -844,6 +844,90 @@ final class WorkspacePullRequestSidebarTests: XCTestCase { ) } + func testHiddenPullRequestsDoNotSchedulePullRequestPollingFromBranchReports() throws { + let defaults = UserDefaults.standard + let previousWatchGitStatus = defaults.object(forKey: SidebarWorkspaceDetailDefaults.watchGitStatusKey) + let previousShowPullRequests = defaults.object(forKey: SidebarWorkspaceDetailDefaults.showPullRequestsKey) + defer { + restoreUserDefault(previousWatchGitStatus, key: SidebarWorkspaceDetailDefaults.watchGitStatusKey) + restoreUserDefault(previousShowPullRequests, key: SidebarWorkspaceDetailDefaults.showPullRequestsKey) + } + + defaults.set(true, forKey: SidebarWorkspaceDetailDefaults.watchGitStatusKey) + defaults.set(false, forKey: SidebarWorkspaceDetailDefaults.showPullRequestsKey) + + let manager = TabManager() + let workspace = try XCTUnwrap(manager.selectedWorkspace) + let panelId = try XCTUnwrap(workspace.focusedPanelId) + + manager.updateSurfaceGitBranch( + tabId: workspace.id, + surfaceId: panelId, + branch: "issue-2746-rate-limit", + isDirty: false + ) + + XCTAssertEqual(workspace.panelGitBranches[panelId]?.branch, "issue-2746-rate-limit") + XCTAssertTrue( + manager.workspacePullRequestTrackedPanelIdsForTesting(workspaceId: workspace.id).isEmpty, + "Branch reports should keep branch metadata but must not arm any PR polling while sidebar.showPullRequests is false." + ) + } + + func testDisablingPullRequestSidebarClearsCachedPullRequestsWithoutClearingBranches() throws { + let defaults = UserDefaults.standard + let previousWatchGitStatus = defaults.object(forKey: SidebarWorkspaceDetailDefaults.watchGitStatusKey) + let previousShowPullRequests = defaults.object(forKey: SidebarWorkspaceDetailDefaults.showPullRequestsKey) + defer { + restoreUserDefault(previousWatchGitStatus, key: SidebarWorkspaceDetailDefaults.watchGitStatusKey) + restoreUserDefault(previousShowPullRequests, key: SidebarWorkspaceDetailDefaults.showPullRequestsKey) + } + + defaults.set(true, forKey: SidebarWorkspaceDetailDefaults.watchGitStatusKey) + defaults.set(true, forKey: SidebarWorkspaceDetailDefaults.showPullRequestsKey) + + let manager = TabManager() + let workspace = try XCTUnwrap(manager.selectedWorkspace) + let panelId = try XCTUnwrap(workspace.focusedPanelId) + let url = try XCTUnwrap(URL(string: "https://github.com/manaflow-ai/cmux/pull/2746")) + + workspace.updatePanelGitBranch( + panelId: panelId, + branch: "issue-2746-rate-limit", + isDirty: false + ) + workspace.updatePanelPullRequest( + panelId: panelId, + number: 2746, + label: "PR", + url: url, + status: .open, + branch: "issue-2746-rate-limit" + ) + XCTAssertNotNil(workspace.panelPullRequests[panelId]) + + defaults.set(false, forKey: SidebarWorkspaceDetailDefaults.showPullRequestsKey) + manager.sidebarGitMetadataWatchSettingsDidChangeForTesting() + + XCTAssertEqual(workspace.panelGitBranches[panelId]?.branch, "issue-2746-rate-limit") + XCTAssertNil(workspace.panelPullRequests[panelId]) + XCTAssertNil(workspace.pullRequest) + XCTAssertTrue( + manager.workspacePullRequestTrackedPanelIdsForTesting(workspaceId: workspace.id).isEmpty, + "Disabling PR visibility should clear PR state and polling without disabling branch metadata." + ) + + defaults.set(true, forKey: SidebarWorkspaceDetailDefaults.showPullRequestsKey) + manager.sidebarGitMetadataWatchSettingsDidChangeForTesting() + + XCTAssertEqual(workspace.panelGitBranches[panelId]?.branch, "issue-2746-rate-limit") + XCTAssertEqual( + manager.workspacePullRequestTrackedPanelIdsForTesting(workspaceId: workspace.id), + Set([panelId]), + "Re-enabling PR visibility should restart PR polling from preserved branch metadata." + ) + } + func testReenablingGitWatchRestartsRefreshFromCurrentPanelDirectories() throws { let defaults = UserDefaults.standard let previousWatchGitStatus = defaults.object(forKey: SidebarWorkspaceDetailDefaults.watchGitStatusKey)