diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 8145ea58..a37dcde5 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -11140,7 +11140,7 @@ enum SidebarTrailingAccessoryWidthPolicy { // 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. +// and bridge only sidebar-visible workspace changes into local state. // 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 { @@ -11772,7 +11772,7 @@ private struct TabItemView: View, Equatable { } } .onReceive( - tab.objectWillChange + tab.sidebarObservationPublisher .receive(on: RunLoop.main) // Prompt-time sidebar telemetry can arrive as a short burst // (pwd, branch, PR, shell state). Coalesce that burst so the diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 8f04ce01..e4bf9717 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -112,7 +112,7 @@ func cmuxInheritedSurfaceConfig( return config } -struct SidebarStatusEntry { +struct SidebarStatusEntry: Equatable { let key: String let value: String let icon: String? @@ -143,7 +143,7 @@ struct SidebarStatusEntry { } } -struct SidebarMetadataBlock { +struct SidebarMetadataBlock: Equatable { let key: String let markdown: String let priority: Int @@ -4896,14 +4896,14 @@ enum SidebarLogLevel: String { case error } -struct SidebarLogEntry { +struct SidebarLogEntry: Equatable { let message: String let level: SidebarLogLevel let source: String? let timestamp: Date } -struct SidebarProgressState { +struct SidebarProgressState: Equatable { let value: Double let label: String? } @@ -4913,6 +4913,14 @@ struct SidebarGitBranchState: Equatable { let isDirty: Bool } +private struct SidebarPanelObservationState: Equatable { + let panelIds: [UUID] + + init(panels: [UUID: any Panel]) { + panelIds = panels.keys.sorted { $0.uuidString < $1.uuidString } + } +} + enum WorkspaceRemoteConnectionState: String { case disconnected case connecting @@ -5584,6 +5592,47 @@ final class Workspace: Identifiable, ObservableObject { var agentPIDs: [String: pid_t] = [:] private var restoredTerminalScrollbackByPanelId: [UUID: String] = [:] + private func sidebarObservationSignal( + _ publisher: Published.Publisher + ) -> AnyPublisher { + publisher + .dropFirst() + .removeDuplicates() + .map { _ in () } + .eraseToAnyPublisher() + } + + lazy var sidebarObservationPublisher: AnyPublisher = { + let publishers: [AnyPublisher] = [ + sidebarObservationSignal($title), + sidebarObservationSignal($isPinned), + sidebarObservationSignal($customColor), + sidebarObservationSignal($currentDirectory), + $panels + .map(SidebarPanelObservationState.init) + .dropFirst() + .removeDuplicates() + .map { _ in () } + .eraseToAnyPublisher(), + sidebarObservationSignal($panelDirectories), + sidebarObservationSignal($statusEntries), + sidebarObservationSignal($metadataBlocks), + sidebarObservationSignal($logEntries), + sidebarObservationSignal($progress), + sidebarObservationSignal($gitBranch), + sidebarObservationSignal($panelGitBranches), + sidebarObservationSignal($pullRequest), + sidebarObservationSignal($panelPullRequests), + sidebarObservationSignal($remoteConfiguration), + sidebarObservationSignal($remoteConnectionState), + sidebarObservationSignal($remoteConnectionDetail), + sidebarObservationSignal($activeRemoteTerminalSessionCount), + sidebarObservationSignal($listeningPorts), + ] + + return Publishers.MergeMany(publishers).eraseToAnyPublisher() + }() + private static func isProxyOnlyRemoteError(_ detail: String) -> Bool { let lowered = detail.lowercased() return lowered.contains("remote proxy") diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index 457a5367..8a9687af 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -2191,6 +2191,54 @@ final class WorkspacePanelGitBranchTests: XCTestCase { ) } + func testSidebarObservationPublisherEmitsForFocusedGitBranchChangesOnlyOncePerState() { + let workspace = Workspace() + guard let panelId = workspace.focusedPanelId else { + XCTFail("Expected initial focused panel") + return + } + + var publishCount = 0 + let cancellable = workspace.sidebarObservationPublisher.sink { + publishCount += 1 + } + defer { cancellable.cancel() } + + workspace.updatePanelGitBranch(panelId: panelId, branch: "main", isDirty: false) + let baselinePublishCount = publishCount + XCTAssertGreaterThan( + baselinePublishCount, + 0, + "Expected focused git branch updates to invalidate sidebar rows" + ) + + workspace.updatePanelGitBranch(panelId: panelId, branch: "main", isDirty: false) + XCTAssertEqual( + publishCount, + baselinePublishCount, + "Expected identical git metadata refreshes to be ignored by sidebar rows" + ) + } + + func testSidebarObservationPublisherIgnoresRemoteHeartbeatOnlyChanges() { + let workspace = Workspace() + + var publishCount = 0 + let cancellable = workspace.sidebarObservationPublisher.sink { + publishCount += 1 + } + defer { cancellable.cancel() } + + workspace.remoteHeartbeatCount = 1 + workspace.remoteLastHeartbeatAt = Date() + + XCTAssertEqual( + publishCount, + 0, + "Expected non-visible remote heartbeat updates to avoid invalidating sidebar rows" + ) + } + @MainActor func testSidebarPullRequestsTrackFocusedPanelOnly() { let workspace = Workspace()