Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Sources/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
91 changes: 76 additions & 15 deletions Sources/Workspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ func cmuxInheritedSurfaceConfig(
return config
}

struct SidebarStatusEntry {
struct SidebarStatusEntry: Equatable {
let key: String
let value: String
let icon: String?
Expand Down Expand Up @@ -143,7 +143,7 @@ struct SidebarStatusEntry {
}
}

struct SidebarMetadataBlock {
struct SidebarMetadataBlock: Equatable {
let key: String
let markdown: String
let priority: Int
Expand Down Expand Up @@ -4896,23 +4896,31 @@ 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?
}

struct SidebarGitBranchState {
struct SidebarGitBranchState: Equatable {
let branch: String
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
Expand Down Expand Up @@ -5584,6 +5592,47 @@ final class Workspace: Identifiable, ObservableObject {
var agentPIDs: [String: pid_t] = [:]
private var restoredTerminalScrollbackByPanelId: [UUID: String] = [:]

private func sidebarObservationSignal<Value: Equatable>(
_ publisher: Published<Value>.Publisher
) -> AnyPublisher<Void, Never> {
publisher
.dropFirst()
.removeDuplicates()
.map { _ in () }
.eraseToAnyPublisher()
}

lazy var sidebarObservationPublisher: AnyPublisher<Void, Never> = {
let publishers: [AnyPublisher<Void, Never>] = [
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")
Expand Down Expand Up @@ -6468,22 +6517,32 @@ final class Workspace: Identifiable, ObservableObject {
panelGitBranches[panelId] = state
}
if branchChanged {
panelPullRequests.removeValue(forKey: panelId)
if panelId == focusedPanelId {
if panelPullRequests[panelId] != nil {
panelPullRequests.removeValue(forKey: panelId)
}
if panelId == focusedPanelId, pullRequest != nil {
pullRequest = nil
}
}
if panelId == focusedPanelId {
if panelId == focusedPanelId, gitBranch != state {
gitBranch = state
}
}

func clearPanelGitBranch(panelId: UUID) {
panelGitBranches.removeValue(forKey: panelId)
panelPullRequests.removeValue(forKey: panelId)
if panelGitBranches[panelId] != nil {
panelGitBranches.removeValue(forKey: panelId)
}
if panelPullRequests[panelId] != nil {
panelPullRequests.removeValue(forKey: panelId)
}
if panelId == focusedPanelId {
gitBranch = nil
pullRequest = nil
if gitBranch != nil {
gitBranch = nil
}
if pullRequest != nil {
pullRequest = nil
}
}
}

Expand Down Expand Up @@ -6539,14 +6598,16 @@ final class Workspace: Identifiable, ObservableObject {
if existing != state {
panelPullRequests[panelId] = state
}
if panelId == focusedPanelId {
if panelId == focusedPanelId, pullRequest != state {
pullRequest = state
}
}

func clearPanelPullRequest(panelId: UUID) {
panelPullRequests.removeValue(forKey: panelId)
if panelId == focusedPanelId {
if panelPullRequests[panelId] != nil {
panelPullRequests.removeValue(forKey: panelId)
}
if panelId == focusedPanelId, pullRequest != nil {
pullRequest = nil
}
}
Expand Down
128 changes: 128 additions & 0 deletions cmuxTests/WorkspaceUnitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import WebKit
import ObjectiveC.runtime
import Bonsplit
import UserNotifications
import Combine

#if canImport(cmux_DEV)
@testable import cmux_DEV
Expand Down Expand Up @@ -2111,6 +2112,133 @@ final class WorkspacePanelGitBranchTests: XCTestCase {
XCTAssertEqual(ordered.map(\.isDirty), [false, true])
}

func testUpdatingFocusedPanelGitBranchWithSameStateDoesNotRepublishWorkspace() {
let workspace = Workspace()
guard let panelId = workspace.focusedPanelId else {
XCTFail("Expected initial focused panel")
return
}

var publishCount = 0
let cancellable = workspace.objectWillChange.sink { _ in
publishCount += 1
}
defer { cancellable.cancel() }

workspace.updatePanelGitBranch(panelId: panelId, branch: "main", isDirty: false)
let baselinePublishCount = publishCount

XCTAssertGreaterThan(
baselinePublishCount,
0,
"Expected the first focused branch update to publish workspace changes"
)

workspace.updatePanelGitBranch(panelId: panelId, branch: "main", isDirty: false)

XCTAssertEqual(
publishCount,
baselinePublishCount,
"Expected identical focused branch refreshes to avoid extra workspace publishes"
)
}

func testUpdatingFocusedPanelPullRequestWithSameStateDoesNotRepublishWorkspace() {
let workspace = Workspace()
guard let panelId = workspace.focusedPanelId else {
XCTFail("Expected initial focused panel")
return
}

workspace.updatePanelGitBranch(panelId: panelId, branch: "feature/sidebar-pr", isDirty: false)

var publishCount = 0
let cancellable = workspace.objectWillChange.sink { _ in
publishCount += 1
}
defer { cancellable.cancel() }

let pullRequestURL = URL(string: "https://github.com/manaflow-ai/cmux/pull/2388")!
workspace.updatePanelPullRequest(
panelId: panelId,
number: 2388,
label: "PR",
url: pullRequestURL,
status: .open,
branch: "feature/sidebar-pr"
)
let baselinePublishCount = publishCount

XCTAssertGreaterThan(
baselinePublishCount,
0,
"Expected the first focused pull request update to publish workspace changes"
)

workspace.updatePanelPullRequest(
panelId: panelId,
number: 2388,
label: "PR",
url: pullRequestURL,
status: .open,
branch: "feature/sidebar-pr"
)

XCTAssertEqual(
publishCount,
baselinePublishCount,
"Expected identical focused pull request refreshes to avoid extra workspace publishes"
)
}

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()
Expand Down
Loading