diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 57706817..936472ef 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -10893,9 +10893,15 @@ enum SidebarTrailingAccessoryWidthPolicy { // the parent rebuilds with unchanged values. Without this, every TabManager // or NotificationStore publish causes ALL tab items to re-evaluate (~18% of // main thread during typing). If you add new properties, update == below. +// Reactive workspace state inside the row must not rely on parent diffs alone: +// `.equatable()` can otherwise leave sidebar badges/details stale until an +// unrelated parent change sneaks through. Keep the workspace reference plain +// and bridge its objectWillChange into local state instead. // Do NOT add @EnvironmentObject or new @Binding without updating ==. // Do NOT remove .equatable() from the ForEach call site in VerticalTabsSidebar. private struct TabItemView: View, Equatable { + private static let workspaceObservationCoalesceInterval: RunLoop.SchedulerTimeType.Stride = .milliseconds(40) + // Closures, Bindings, and object references are excluded from == // because they're recreated every parent eval but don't affect rendering. nonisolated static func == (lhs: TabItemView, rhs: TabItemView) -> Bool { @@ -10921,7 +10927,7 @@ private struct TabItemView: View, Equatable { let tabManager: TabManager let notificationStore: TerminalNotificationStore @Environment(\.colorScheme) private var colorScheme - @ObservedObject var tab: Tab + let tab: Tab let index: Int let isActive: Bool let workspaceShortcutDigit: Int? @@ -10941,6 +10947,7 @@ private struct TabItemView: View, Equatable { let remoteContextMenuWorkspaceIds: [UUID] let allRemoteContextMenuTargetsConnecting: Bool let allRemoteContextMenuTargetsDisconnected: Bool + @State private var workspaceObservationGeneration: UInt64 = 0 @State private var isHovering = false @State private var rowHeight: CGFloat = 1 @AppStorage(ShortcutHintDebugSettings.sidebarHintXKey) private var sidebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultSidebarHintX @@ -11148,6 +11155,7 @@ private struct TabItemView: View, Equatable { } var body: some View { + let _ = workspaceObservationGeneration let closeWorkspaceTooltip = String(localized: "sidebar.closeWorkspace.tooltip", defaultValue: "Close Workspace") let protectedWorkspaceTooltip = String( localized: "sidebar.pinnedWorkspaceProtected.tooltip", @@ -11484,6 +11492,16 @@ private struct TabItemView: View, Equatable { .offset(y: index == 0 ? 0 : -(rowSpacing / 2)) } } + .onReceive( + tab.objectWillChange + .receive(on: RunLoop.main) + // Prompt-time sidebar telemetry can arrive as a short burst + // (pwd, branch, PR, shell state). Coalesce that burst so the + // row redraws once with the settled state instead of blinking. + .debounce(for: Self.workspaceObservationCoalesceInterval, scheduler: RunLoop.main) + ) { _ in + workspaceObservationGeneration &+= 1 + } .onDrag { #if DEBUG dlog("sidebar.onDrag tab=\(tab.id.uuidString.prefix(5))") diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 273cc43a..e0ecf247 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -698,6 +698,7 @@ 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 nonisolated static let workspacePullRequestProbeTimeout: TimeInterval = 5.0 @Published var selectedTabId: UUID? { willSet { @@ -809,6 +810,7 @@ class TabManager: ObservableObject { } } private var agentPIDSweepTimer: DispatchSourceTimer? + private var workspaceGitMetadataPollTimer: DispatchSourceTimer? #if DEBUG private var debugWorkspaceSwitchCounter: UInt64 = 0 private var debugWorkspaceSwitchId: UInt64 = 0 @@ -855,7 +857,7 @@ class TabManager: ObservableObject { }) startAgentPIDSweepTimer() - + startWorkspaceGitMetadataPollTimer() #if DEBUG setupUITestFocusShortcutsIfNeeded() setupSplitCloseRightUITestIfNeeded() @@ -867,6 +869,7 @@ class TabManager: ObservableObject { deinit { workspaceCycleCooldownTask?.cancel() agentPIDSweepTimer?.cancel() + workspaceGitMetadataPollTimer?.cancel() } // MARK: - Agent PID Sweep @@ -888,6 +891,53 @@ class TabManager: ObservableObject { agentPIDSweepTimer = timer } + /// Periodically refreshes git/PR metadata for tracked workspace branches so + /// remote GitHub state changes (e.g. PR open -> merged) reach sidebar state + /// even when the local branch/directory does not change. + private func startWorkspaceGitMetadataPollTimer() { + let timer = DispatchSource.makeTimerSource(queue: .global(qos: .utility)) + let interval = Self.workspaceGitMetadataPollInterval + 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.refreshTrackedWorkspaceGitMetadata() + } + } + timer.resume() + workspaceGitMetadataPollTimer = timer + } + + private func refreshTrackedWorkspaceGitMetadata() { + let activeProbeKeys = Set(workspaceGitProbeGenerationByKey.keys) + + for workspace in tabs { + var candidatePanelIds = Set(workspace.panelGitBranches.keys) + candidatePanelIds.formUnion(workspace.panelPullRequests.keys) + + if candidatePanelIds.isEmpty, + let focusedPanelId = workspace.focusedPanelId, + workspace.gitBranch != nil || workspace.pullRequest != nil { + candidatePanelIds.insert(focusedPanelId) + } + + for panelId in candidatePanelIds { + let probeKey = WorkspaceGitProbeKey(workspaceId: workspace.id, panelId: panelId) + guard !activeProbeKeys.contains(probeKey) else { continue } + scheduleWorkspaceGitMetadataRefreshIfPossible( + workspaceId: workspace.id, + panelId: panelId, + reason: "periodicPoll" + ) + } + } + } + + func refreshTrackedWorkspaceGitMetadataForTesting() { + refreshTrackedWorkspaceGitMetadata() + } + private func sweepStaleAgentPIDs() { for tab in tabs { var keysToRemove: [String] = [] @@ -1699,6 +1749,69 @@ class TabManager: ObservableObject { } } + private nonisolated static let fallbackCommandSearchDirectories: [String] = [ + "/opt/homebrew/bin", + "/usr/local/bin", + "/opt/local/bin", + ] + + nonisolated static func resolvedCommandPathForTesting( + executable: String, + environment: [String: String], + fallbackDirectories: [String] + ) -> String? { + resolvedCommandPath( + executable: executable, + environment: environment, + fallbackDirectories: fallbackDirectories + ) + } + + private nonisolated static func resolvedCommandPath( + executable: String, + environment: [String: String] = ProcessInfo.processInfo.environment, + fallbackDirectories: [String] = fallbackCommandSearchDirectories + ) -> String? { + guard !executable.isEmpty else { return nil } + let fileManager = FileManager.default + if executable.contains("/") { + return fileManager.isExecutableFile(atPath: executable) ? executable : nil + } + + var searchDirectories: [String] = [] + var seenDirectories: Set = [] + + func appendSearchPath(_ path: String?) { + guard let path else { return } + for rawComponent in path.split(separator: ":") { + let component = String(rawComponent).trimmingCharacters(in: .whitespacesAndNewlines) + guard !component.isEmpty, + seenDirectories.insert(component).inserted else { + continue + } + searchDirectories.append(component) + } + } + + appendSearchPath(environment["PATH"]) + appendSearchPath(getenv("PATH").map { String(cString: $0) }) + if let bundledBinPath = Bundle.main.resourceURL?.appendingPathComponent("bin").path { + appendSearchPath(bundledBinPath) + } + fallbackDirectories.forEach { appendSearchPath($0) } + appendSearchPath("/usr/bin:/bin:/usr/sbin:/sbin") + + for directory in searchDirectories { + let candidate = URL(fileURLWithPath: directory, isDirectory: true) + .appendingPathComponent(executable) + .path + if fileManager.isExecutableFile(atPath: candidate) { + return candidate + } + } + return nil + } + private nonisolated static func runCommand( directory: String, executable: String, @@ -1728,8 +1841,13 @@ class TabManager: ObservableObject { let process = Process() let stdout = Pipe() let stderr = Pipe() - process.executableURL = URL(fileURLWithPath: "/usr/bin/env") - process.arguments = [executable] + arguments + if let resolvedExecutable = resolvedCommandPath(executable: executable) { + process.executableURL = URL(fileURLWithPath: resolvedExecutable) + process.arguments = arguments + } else { + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = [executable] + arguments + } process.currentDirectoryURL = URL(fileURLWithPath: directory) process.standardOutput = stdout process.standardError = stderr diff --git a/cmuxTests/TabManagerUnitTests.swift b/cmuxTests/TabManagerUnitTests.swift index a31106ec..78920716 100644 --- a/cmuxTests/TabManagerUnitTests.swift +++ b/cmuxTests/TabManagerUnitTests.swift @@ -23,6 +23,96 @@ func drainMainQueue() { XCTWaiter().wait(for: [expectation], timeout: 1.0) } +@discardableResult +private func waitForCondition( + timeout: TimeInterval = 3.0, + pollInterval: TimeInterval = 0.05, + file: StaticString = #filePath, + line: UInt = #line, + _ condition: @escaping () -> Bool +) -> Bool { + if condition() { + return true + } + + let expectation = XCTestExpectation(description: "wait for condition") + let deadline = Date().addingTimeInterval(timeout) + + func poll() { + if condition() { + expectation.fulfill() + return + } + guard Date() < deadline else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + pollInterval) { + poll() + } + } + + DispatchQueue.main.async { + poll() + } + + let result = XCTWaiter().wait(for: [expectation], timeout: timeout + pollInterval + 0.1) + if result != .completed { + XCTFail("Timed out waiting for condition", file: file, line: line) + return false + } + return true +} + +private struct ProcessRunResult { + let status: Int32 + let stdout: String + let stderr: String +} + +private func runProcess( + executablePath: String, + arguments: [String], + environment: [String: String]? = nil, + currentDirectoryURL: URL? = nil +) throws -> ProcessRunResult { + let process = Process() + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.executableURL = URL(fileURLWithPath: executablePath) + process.arguments = arguments + process.environment = environment + process.currentDirectoryURL = currentDirectoryURL + process.standardInput = FileHandle.nullDevice + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + try process.run() + process.waitUntilExit() + return ProcessRunResult( + status: process.terminationStatus, + stdout: String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "", + stderr: String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + ) +} + +private func runGit( + _ arguments: [String], + in directoryURL: URL, + file: StaticString = #filePath, + line: UInt = #line +) throws -> String { + let result = try runProcess( + executablePath: "/usr/bin/env", + arguments: ["git"] + arguments, + currentDirectoryURL: directoryURL + ) + XCTAssertEqual( + result.status, + 0, + "git \(arguments.joined(separator: " ")) failed: \(result.stderr)", + file: file, + line: line + ) + return result.stdout +} + @MainActor final class TabManagerChildExitCloseTests: XCTestCase { func testChildExitOnLastPanelClosesSelectedWorkspaceAndKeepsIndexStable() { @@ -210,6 +300,88 @@ final class TabManagerPullRequestProbeTests: XCTestCase { valid ) } + + func testResolvedCommandPathFallsBackOutsideAppPATH() throws { + let fileManager = FileManager.default + let tempDir = fileManager.temporaryDirectory.appendingPathComponent( + "cmux-command-path-\(UUID().uuidString)", + isDirectory: true + ) + try fileManager.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: tempDir) } + + let executableName = "cmux-gh-test-\(UUID().uuidString)" + let executableURL = tempDir.appendingPathComponent(executableName) + try """ + #!/bin/sh + exit 0 + """.write(to: executableURL, atomically: true, encoding: .utf8) + try fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executableURL.path) + + XCTAssertEqual( + TabManager.resolvedCommandPathForTesting( + executable: executableName, + environment: ["PATH": "/usr/bin:/bin"], + fallbackDirectories: [tempDir.path] + ), + executableURL.path + ) + } + + func testPeriodicWorkspaceGitMetadataRefreshClearsStalePullRequestAfterBranchReset() throws { + let fileManager = FileManager.default + let repoURL = fileManager.temporaryDirectory.appendingPathComponent("cmux-git-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) + try runGit(["checkout", "-b", "feature/sidebar-pr"], 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: "feature/sidebar-pr", isDirty: false) + workspace.updatePanelPullRequest( + panelId: panelId, + number: 1052, + label: "PR", + url: try XCTUnwrap(URL(string: "https://github.com/manaflow-ai/cmux/pull/1052")), + status: .open, + branch: "feature/sidebar-pr" + ) + + XCTAssertEqual(workspace.panelGitBranches[panelId]?.branch, "feature/sidebar-pr") + XCTAssertEqual(workspace.panelPullRequests[panelId]?.number, 1052) + XCTAssertEqual(workspace.sidebarPullRequestsInDisplayOrder().map(\.number), [1052]) + + try runGit(["checkout", "main"], in: repoURL) + + manager.refreshTrackedWorkspaceGitMetadataForTesting() + + XCTAssertTrue( + waitForCondition { + workspace.panelGitBranches[panelId]?.branch == "main" + && workspace.panelPullRequests[panelId] == nil + } + ) + XCTAssertEqual(workspace.gitBranch?.branch, "main") + XCTAssertNil(workspace.pullRequest) + XCTAssertTrue(workspace.sidebarPullRequestsInDisplayOrder().isEmpty) + } }