diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 73cd4e460953..bb1fa643e281 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1546,6 +1546,9 @@ private final class WindowTmuxWorkspacePaneOverlayController: NSObject { ) ) super.init() + model.onStateChange = { [weak self] in + self?.renderModelState() + } containerView.translatesAutoresizingMaskIntoConstraints = false containerView.wantsLayer = true containerView.layer?.backgroundColor = NSColor.clear.cgColor @@ -1592,25 +1595,22 @@ private final class WindowTmuxWorkspacePaneOverlayController: NSObject { guard ensureInstalled() else { return } if let state { model.apply(state) - hostingView.rootView = TmuxWorkspacePaneOverlayView( - unreadRects: model.unreadRects, - flashRect: model.flashRect, - flashStartedAt: model.flashStartedAt, - flashReason: model.flashReason - ) - containerView.alphaValue = 1 - containerView.isHidden = false } else { model.clear() - hostingView.rootView = TmuxWorkspacePaneOverlayView( - unreadRects: [], - flashRect: nil, - flashStartedAt: nil, - flashReason: nil - ) - containerView.alphaValue = 0 - containerView.isHidden = true } + renderModelState() + } + + private func renderModelState() { + guard ensureInstalled() else { return } + hostingView.rootView = TmuxWorkspacePaneOverlayView( + unreadRects: model.unreadRects, + flashRect: model.flashRect, + flashStartedAt: model.flashStartedAt, + flashReason: model.flashReason + ) + containerView.alphaValue = model.hasVisibleContent ? 1 : 0 + containerView.isHidden = !model.hasVisibleContent } } diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index e2c418078f88..3350a91247a1 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -766,8 +766,13 @@ class TabManager: ObservableObject { /// Static so port ranges don't overlap across multiple windows (each window has its own TabManager). 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 + // Background PR metadata is best-effort. Keep it infrequent so idle windows + // don't repeatedly spawn `gh` when nothing is changing. + private static let workspaceGitMetadataPollInterval: TimeInterval = 5 * 60 + private static let selectedWorkspaceGitMetadataPollInterval: TimeInterval = 2 * 60 + // Keep individual `gh` probes long enough for cold launches and slower + // VPN/GitHub Enterprise responses. The lower poll frequency already limits + // idle process churn, so an overly aggressive timeout hurts correctness. private nonisolated static let workspacePullRequestProbeTimeout: TimeInterval = 5.0 @Published var selectedTabId: UUID? { willSet { @@ -829,6 +834,7 @@ class TabManager: ObservableObject { if let selectedTabId = self.selectedTabId { self.dismissFocusedPanelNotificationIfActive(tabId: selectedTabId) } + self.scheduleSelectedWorkspaceGitMetadataRefresh(reason: "selectionChange") #if DEBUG let dtMs = self.debugWorkspaceSwitchStartTime > 0 ? (CACurrentMediaTime() - self.debugWorkspaceSwitchStartTime) * 1000 @@ -1046,6 +1052,26 @@ class TabManager: ObservableObject { ) } + private func scheduleSelectedWorkspaceGitMetadataRefresh(reason: String) { + guard let workspace = selectedWorkspace, + let focusedPanelId = workspace.focusedPanelId else { + return + } + + // Preserve any in-flight bootstrap retry sequence for this workspace. + let probeKey = WorkspaceGitProbeKey(workspaceId: workspace.id, panelId: focusedPanelId) + guard workspaceGitProbeGenerationByKey[probeKey] == nil, + workspaceGitProbeTimersByKey[probeKey] == nil else { + return + } + + scheduleWorkspaceGitMetadataRefreshIfPossible( + workspaceId: workspace.id, + panelId: focusedPanelId, + reason: reason + ) + } + func refreshTrackedWorkspaceGitMetadataForTesting() { refreshTrackedWorkspaceGitMetadata() } @@ -1061,6 +1087,11 @@ class TabManager: ObservableObject { ) } + func workspaceGitProbeAttemptCountForTesting(workspaceId: UUID, panelId: UUID) -> Int { + let probeKey = WorkspaceGitProbeKey(workspaceId: workspaceId, panelId: panelId) + return workspaceGitProbeTimersByKey[probeKey]?.count ?? 0 + } + func activeWorkspaceGitProbePanelIdsForTesting(workspaceId: UUID) -> Set { let probeKeys = Set(workspaceGitProbeGenerationByKey.keys.filter { $0.workspaceId == workspaceId }) .union(workspaceGitProbeTimersByKey.keys.filter { $0.workspaceId == workspaceId }) diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index 13b877817352..785c6441bca2 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -102,14 +102,25 @@ struct TmuxWorkspacePaneOverlayRenderState: Equatable { } @MainActor -final class TmuxWorkspacePaneOverlayModel: ObservableObject { - @Published private(set) var unreadRects: [CGRect] = [] - @Published private(set) var flashRect: CGRect? - @Published private(set) var flashStartedAt: Date? - @Published private(set) var flashReason: WorkspaceAttentionFlashReason? +final class TmuxWorkspacePaneOverlayModel { + private(set) var unreadRects: [CGRect] = [] + private(set) var flashRect: CGRect? + private(set) var flashStartedAt: Date? + private(set) var flashReason: WorkspaceAttentionFlashReason? private var lastWorkspaceId: UUID? - private var lastFlashToken: UInt64? + private var lastObservedFlashToken: UInt64? + private var runningFlashToken: UInt64? + private var flashResetWorkItem: DispatchWorkItem? + var onStateChange: (() -> Void)? + + var showsAnimatedFlash: Bool { + flashRect != nil && flashStartedAt != nil + } + + var hasVisibleContent: Bool { + !unreadRects.isEmpty || showsAnimatedFlash + } func apply( _ state: TmuxWorkspacePaneOverlayRenderState, @@ -122,26 +133,88 @@ final class TmuxWorkspacePaneOverlayModel: ObservableObject { let didChangeWorkspace = lastWorkspaceId != state.workspaceId if didChangeWorkspace { lastWorkspaceId = state.workspaceId - lastFlashToken = state.flashToken + lastObservedFlashToken = state.flashToken + runningFlashToken = nil + cancelFlashReset() flashStartedAt = nil return } - if let lastFlashToken, - state.flashToken != lastFlashToken, + let didChangeFlashToken = lastObservedFlashToken != state.flashToken + if didChangeFlashToken { + lastObservedFlashToken = state.flashToken + + if state.flashRect != nil { + startFlash( + workspaceId: state.workspaceId, + flashToken: state.flashToken, + startedAt: now() + ) + } else { + // A new token supersedes any in-flight flash even when layout churn + // temporarily leaves the pane without a window-space rect. + cancelFlashReset() + flashStartedAt = nil + runningFlashToken = nil + } + return + } + + // Preserve an in-flight flash if the pane rect is temporarily missing. + // Once the same token has a rect again, resume from the original start time. + if runningFlashToken == nil, state.flashRect != nil { - flashStartedAt = now() + startFlash( + workspaceId: state.workspaceId, + flashToken: state.flashToken, + startedAt: now() + ) } - self.lastFlashToken = state.flashToken } func clear() { + cancelFlashReset() unreadRects = [] flashRect = nil flashStartedAt = nil flashReason = nil lastWorkspaceId = nil - lastFlashToken = nil + lastObservedFlashToken = nil + runningFlashToken = nil + } + + private func startFlash(workspaceId: UUID, flashToken: UInt64, startedAt: Date) { + flashStartedAt = startedAt + runningFlashToken = flashToken + scheduleFlashReset(workspaceId: workspaceId, flashToken: flashToken) + } + + private func scheduleFlashReset(workspaceId: UUID, flashToken: UInt64) { + cancelFlashReset() + + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + guard self.lastWorkspaceId == workspaceId, + self.runningFlashToken == flashToken else { + return + } + + self.flashStartedAt = nil + // Keep the handled token latched so later layout churn with the same + // token cannot replay a completed flash. A new token or workspace + // change will reset the latch. + self.onStateChange?() + } + flashResetWorkItem = workItem + DispatchQueue.main.asyncAfter( + deadline: .now() + FocusFlashPattern.duration, + execute: workItem + ) + } + + private func cancelFlashReset() { + flashResetWorkItem?.cancel() + flashResetWorkItem = nil } } @@ -152,29 +225,50 @@ struct TmuxWorkspacePaneOverlayView: View { let flashReason: WorkspaceAttentionFlashReason? var body: some View { - TimelineView(.animation) { timeline in - Canvas { context, _ in - for rect in unreadRects { - drawUnreadRing(in: &context, rect: rect) + Group { + if flashRect != nil && flashStartedAt != nil { + TimelineView(.animation) { timeline in + Canvas { context, _ in + renderOverlay( + in: &context, + currentDate: timeline.date + ) + } } - - guard let flashRect, - let flashStartedAt else { return } - let elapsed = timeline.date.timeIntervalSince(flashStartedAt) - let opacity = FocusFlashPattern.opacity(at: elapsed) - guard opacity > 0.001 else { return } - drawFlashRing( - in: &context, - rect: flashRect, - opacity: opacity, - reason: flashReason ?? .notificationArrival - ) + } else if !unreadRects.isEmpty { + Canvas { context, _ in + renderOverlay(in: &context, currentDate: nil) + } + } else { + Color.clear } } .allowsHitTesting(false) .frame(maxWidth: .infinity, maxHeight: .infinity) } + private func renderOverlay( + in context: inout GraphicsContext, + currentDate: Date? + ) { + for rect in unreadRects { + drawUnreadRing(in: &context, rect: rect) + } + + guard let flashRect, + let flashStartedAt, + let currentDate else { return } + let elapsed = currentDate.timeIntervalSince(flashStartedAt) + let opacity = FocusFlashPattern.opacity(at: elapsed) + guard opacity > 0.001 else { return } + drawFlashRing( + in: &context, + rect: flashRect, + opacity: opacity, + reason: flashReason ?? .notificationArrival + ) + } + private func drawUnreadRing(in context: inout GraphicsContext, rect: CGRect) { guard let path = ringPath(for: rect) else { return } var glowContext = context diff --git a/cmuxTests/TabManagerUnitTests.swift b/cmuxTests/TabManagerUnitTests.swift index e3948cbfbbc6..9e6b211d1b0b 100644 --- a/cmuxTests/TabManagerUnitTests.swift +++ b/cmuxTests/TabManagerUnitTests.swift @@ -14,6 +14,7 @@ import UserNotifications #endif let lastSurfaceCloseShortcutDefaultsKey = "closeWorkspaceOnLastSurfaceShortcut" +private let pathMutationLock = NSLock() func drainMainQueue() { let expectation = XCTestExpectation(description: "drain main queue") @@ -122,6 +123,19 @@ private func runGit( return result.stdout } +private func withPrependedPath( + _ path: String, + _ body: () throws -> T +) rethrows -> T { + pathMutationLock.lock() + defer { pathMutationLock.unlock() } + + let originalPath = getenv("PATH").map { String(cString: $0) } ?? "/usr/bin:/bin:/usr/sbin:/sbin" + setenv("PATH", "\(path):\(originalPath)", 1) + defer { setenv("PATH", originalPath, 1) } + return try body() +} + @MainActor final class TabManagerChildExitCloseTests: XCTestCase { func testChildExitOnLastPanelClosesSelectedWorkspaceAndKeepsIndexStable() { @@ -574,6 +588,145 @@ final class TabManagerPullRequestProbeTests: XCTestCase { XCTAssertEqual(workspace.gitBranch?.branch, "feature/sidebar-live-refresh") } + func testSelectingNewWorkspacePreservesInitialGitMetadataRetries() throws { + let fileManager = FileManager.default + let repoURL = fileManager.temporaryDirectory.appendingPathComponent("cmux-git-bootstrap-refresh-\(UUID().uuidString)") + let binURL = fileManager.temporaryDirectory.appendingPathComponent("cmux-git-bootstrap-bin-\(UUID().uuidString)") + try fileManager.createDirectory(at: repoURL, withIntermediateDirectories: true) + try fileManager.createDirectory(at: binURL, withIntermediateDirectories: true) + defer { + try? fileManager.removeItem(at: repoURL) + try? fileManager.removeItem(at: binURL) + } + + 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) + try runGit(["checkout", "-b", "feature/bootstrap-refresh"], in: repoURL) + try runGit(["remote", "add", "origin", "https://github.com/manaflow-ai/cmux.git"], in: repoURL) + + let ghCounterURL = repoURL.appendingPathComponent(".gh-call-count") + let ghScriptURL = binURL.appendingPathComponent("gh") + let ghScript = """ + #!/bin/sh + counter_file='\(ghCounterURL.path)' + command="$1 $2" + if [ "$command" = "pr list" ]; then + count=0 + if [ -f "$counter_file" ]; then + count=$(cat "$counter_file") + fi + count=$((count + 1)) + printf '%s' "$count" > "$counter_file" + if [ "$count" -lt 3 ]; then + echo "transient failure" >&2 + exit 1 + fi + printf '%s' '[{"number":42,"state":"OPEN","url":"https://github.com/manaflow-ai/cmux/pull/42","updatedAt":"2026-04-03T00:00:00Z"}]' + exit 0 + fi + if [ "$command" = "pr checks" ]; then + printf '[]' + exit 0 + fi + echo "unsupported gh invocation: $*" >&2 + exit 1 + """ + try ghScript.write(to: ghScriptURL, atomically: true, encoding: .utf8) + try fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: ghScriptURL.path) + + try withPrependedPath(binURL.path) { + let manager = TabManager() + let workspace = manager.addWorkspace(workingDirectory: repoURL.path) + guard let panelId = workspace.focusedPanelId else { + XCTFail("Expected focused panel in new workspace") + return + } + + drainMainQueue() + drainMainQueue() + + XCTAssertTrue( + waitForCondition(timeout: 3.0) { + workspace.panelPullRequests[panelId]?.number == 42 + } + ) + XCTAssertEqual(workspace.panelGitBranches[panelId]?.branch, "feature/bootstrap-refresh") + XCTAssertEqual(workspace.pullRequest?.number, 42) + } + } + + func testInitialWorkspaceGitMetadataRefreshAllowsSlowGitHubPullRequestProbe() throws { + let fileManager = FileManager.default + let repoURL = fileManager.temporaryDirectory.appendingPathComponent("cmux-git-slow-gh-refresh-\(UUID().uuidString)") + let binURL = fileManager.temporaryDirectory.appendingPathComponent("cmux-git-slow-gh-bin-\(UUID().uuidString)") + try fileManager.createDirectory(at: repoURL, withIntermediateDirectories: true) + try fileManager.createDirectory(at: binURL, withIntermediateDirectories: true) + defer { + try? fileManager.removeItem(at: repoURL) + try? fileManager.removeItem(at: binURL) + } + + 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) + try runGit(["checkout", "-b", "feature/slow-pr-probe"], in: repoURL) + try runGit(["remote", "add", "origin", "https://github.com/manaflow-ai/cmux.git"], in: repoURL) + + let ghScriptURL = binURL.appendingPathComponent("gh") + let ghScript = """ + #!/bin/sh + command="$1 $2" + if [ "$command" = "pr list" ]; then + sleep 3 + printf '%s' '[{"number":43,"state":"OPEN","url":"https://github.com/manaflow-ai/cmux/pull/43","updatedAt":"2026-04-04T00:00:00Z"}]' + exit 0 + fi + if [ "$command" = "pr checks" ]; then + printf '[]' + exit 0 + fi + echo "unsupported gh invocation: $*" >&2 + exit 1 + """ + try ghScript.write(to: ghScriptURL, atomically: true, encoding: .utf8) + try fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: ghScriptURL.path) + + try withPrependedPath(binURL.path) { + let manager = TabManager() + let workspace = manager.addWorkspace(workingDirectory: repoURL.path) + guard let panelId = workspace.focusedPanelId else { + XCTFail("Expected focused panel in new workspace") + return + } + + drainMainQueue() + drainMainQueue() + + XCTAssertTrue( + waitForCondition(timeout: 5.5) { + workspace.panelPullRequests[panelId]?.number == 43 + } + ) + XCTAssertEqual(workspace.panelGitBranches[panelId]?.branch, "feature/slow-pr-probe") + XCTAssertEqual(workspace.pullRequest?.number, 43) + } + } + func testPeriodicWorkspaceGitMetadataRefreshRestoresClearedBranchForStaleTerminal() throws { let fileManager = FileManager.default let repoURL = fileManager.temporaryDirectory.appendingPathComponent( diff --git a/cmuxTests/WindowAndDragTests.swift b/cmuxTests/WindowAndDragTests.swift index 9df30fad5997..9721070228ac 100644 --- a/cmuxTests/WindowAndDragTests.swift +++ b/cmuxTests/WindowAndDragTests.swift @@ -1338,6 +1338,184 @@ final class TmuxWorkspacePaneOverlayTests: XCTestCase { XCTAssertEqual(model.flashReason, .navigation) } + func testTmuxWorkspacePaneOverlayModelClearsFlashAfterDuration() { + let clearExpectation = expectation(description: "flash auto clears") + let startedAt = Date() + let workspaceId = UUID() + let model = TmuxWorkspacePaneOverlayModel() + model.onStateChange = { + if model.flashStartedAt == nil { + clearExpectation.fulfill() + } + } + + model.apply( + TmuxWorkspacePaneOverlayRenderState( + workspaceId: workspaceId, + unreadRects: [], + flashRect: CGRect(x: 10, y: 20, width: 300, height: 200), + flashToken: 1, + flashReason: .notificationArrival + ) + ) + model.apply( + TmuxWorkspacePaneOverlayRenderState( + workspaceId: workspaceId, + unreadRects: [], + flashRect: CGRect(x: 10, y: 20, width: 300, height: 200), + flashToken: 2, + flashReason: .notificationArrival + ), + now: { startedAt } + ) + + XCTAssertEqual(model.flashStartedAt, startedAt) + wait(for: [clearExpectation], timeout: FocusFlashPattern.duration + 0.5) + XCTAssertNil(model.flashStartedAt) + } + + func testTmuxWorkspacePaneOverlayModelDoesNotReplayCompletedFlashToken() { + let clearExpectation = expectation(description: "flash auto clears once") + let startedAt = Date() + let workspaceId = UUID() + let model = TmuxWorkspacePaneOverlayModel() + model.onStateChange = { + if model.flashStartedAt == nil { + clearExpectation.fulfill() + } + } + + model.apply( + TmuxWorkspacePaneOverlayRenderState( + workspaceId: workspaceId, + unreadRects: [], + flashRect: CGRect(x: 10, y: 20, width: 300, height: 200), + flashToken: 1, + flashReason: .notificationArrival + ) + ) + model.apply( + TmuxWorkspacePaneOverlayRenderState( + workspaceId: workspaceId, + unreadRects: [], + flashRect: CGRect(x: 10, y: 20, width: 300, height: 200), + flashToken: 2, + flashReason: .notificationArrival + ), + now: { startedAt } + ) + + XCTAssertEqual(model.flashStartedAt, startedAt) + wait(for: [clearExpectation], timeout: FocusFlashPattern.duration + 0.5) + XCTAssertNil(model.flashStartedAt) + + model.apply( + TmuxWorkspacePaneOverlayRenderState( + workspaceId: workspaceId, + unreadRects: [], + flashRect: CGRect(x: 10, y: 20, width: 300, height: 200), + flashToken: 2, + flashReason: .notificationArrival + ), + now: { startedAt.addingTimeInterval(10) } + ) + + XCTAssertNil(model.flashStartedAt) + XCTAssertFalse(model.showsAnimatedFlash) + } + + func testTmuxWorkspacePaneOverlayModelPreservesFlashAcrossTransientNilRect() { + let startedAt = Date() + let workspaceId = UUID() + let model = TmuxWorkspacePaneOverlayModel() + + model.apply( + TmuxWorkspacePaneOverlayRenderState( + workspaceId: workspaceId, + unreadRects: [], + flashRect: CGRect(x: 10, y: 20, width: 300, height: 200), + flashToken: 1, + flashReason: .notificationArrival + ) + ) + model.apply( + TmuxWorkspacePaneOverlayRenderState( + workspaceId: workspaceId, + unreadRects: [], + flashRect: CGRect(x: 10, y: 20, width: 300, height: 200), + flashToken: 2, + flashReason: .notificationArrival + ), + now: { startedAt } + ) + model.apply( + TmuxWorkspacePaneOverlayRenderState( + workspaceId: workspaceId, + unreadRects: [], + flashRect: nil, + flashToken: 2, + flashReason: .notificationArrival + ) + ) + + XCTAssertEqual(model.flashStartedAt, startedAt) + XCTAssertFalse(model.showsAnimatedFlash) + + model.apply( + TmuxWorkspacePaneOverlayRenderState( + workspaceId: workspaceId, + unreadRects: [], + flashRect: CGRect(x: 10, y: 20, width: 300, height: 200), + flashToken: 2, + flashReason: .notificationArrival + ) + ) + + XCTAssertEqual(model.flashStartedAt, startedAt) + XCTAssertTrue(model.showsAnimatedFlash) + } + + func testTmuxWorkspacePaneOverlayModelStartsPendingFlashWhenRectReturns() { + let startedAt = Date() + let workspaceId = UUID() + let model = TmuxWorkspacePaneOverlayModel() + + model.apply( + TmuxWorkspacePaneOverlayRenderState( + workspaceId: workspaceId, + unreadRects: [], + flashRect: nil, + flashToken: 1, + flashReason: .notificationArrival + ) + ) + model.apply( + TmuxWorkspacePaneOverlayRenderState( + workspaceId: workspaceId, + unreadRects: [], + flashRect: nil, + flashToken: 2, + flashReason: .notificationArrival + ) + ) + + XCTAssertNil(model.flashStartedAt) + + model.apply( + TmuxWorkspacePaneOverlayRenderState( + workspaceId: workspaceId, + unreadRects: [], + flashRect: CGRect(x: 10, y: 20, width: 300, height: 200), + flashToken: 2, + flashReason: .notificationArrival + ), + now: { startedAt } + ) + + XCTAssertEqual(model.flashStartedAt, startedAt) + XCTAssertTrue(model.showsAnimatedFlash) + } + func testNavigationFlashUsesNonNotificationPresentation() { XCTAssertNotEqual( WorkspaceAttentionCoordinator.flashStyle(for: .navigation),