From 75d374215816f8aa18f9e7904ab4a962ddf0c020 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 19 Apr 2026 19:17:32 -0700 Subject: [PATCH 1/4] Add regression test for session directory publishing --- cmuxTests/SessionIndexViewTests.swift | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/cmuxTests/SessionIndexViewTests.swift b/cmuxTests/SessionIndexViewTests.swift index 3aca3e53bd49..c1740fffd1a3 100644 --- a/cmuxTests/SessionIndexViewTests.swift +++ b/cmuxTests/SessionIndexViewTests.swift @@ -1,4 +1,5 @@ import AppKit +import Combine import SwiftUI import XCTest @@ -10,6 +11,20 @@ import XCTest @MainActor final class SessionIndexViewTests: XCTestCase { + func testCurrentDirectorySetterDoesNotPublishEqualValue() { + let store = SessionIndexStore() + var emittedValues: [String?] = [] + let cancellable = store.$currentDirectory + .dropFirst() + .sink { emittedValues.append($0) } + defer { cancellable.cancel() } + + store.currentDirectory = "/foo" + store.currentDirectory = "/foo" + + XCTAssertEqual(emittedValues, ["/foo"]) + } + func testSectionPopoverHostCoordinatorSkipsHiddenRefreshes() { let harness = makeHarness() let coordinator = harness.host.makeCoordinator() From 156d116e02d03c0661498d989744c886b055f063 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 19 Apr 2026 19:18:48 -0700 Subject: [PATCH 2/4] Fix ContentView directory sync feedback loop --- Sources/ContentView.swift | 60 ++++++++++++++++++--------- Sources/SessionIndexStore.swift | 5 +++ cmuxTests/SessionIndexViewTests.swift | 4 +- 3 files changed, 48 insertions(+), 21 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index b99c4652eb92..3460cd2a48b1 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1878,6 +1878,39 @@ private func installFileDropOverlayWhenReady( } } +@MainActor +private final class SelectedWorkspaceDirectoryObserver: ObservableObject { + @Published private(set) var directoryChangeGeneration: UInt64 = 0 + private weak var tabManager: TabManager? + private var cancellable: AnyCancellable? + + func wire(tabManager: TabManager) { + guard self.tabManager !== tabManager || cancellable == nil else { return } + self.tabManager = tabManager + cancellable = tabManager.$selectedTabId + .compactMap { [weak tabManager] tabId -> Workspace? in + guard let tabId, let tabManager else { return nil } + return tabManager.tabs.first(where: { $0.id == tabId }) + } + .removeDuplicates(by: { $0.id == $1.id }) + .map { workspace -> AnyPublisher<(UUID, String), Never> in + workspace.$currentDirectory + .map { (workspace.id, $0) } + .eraseToAnyPublisher() + } + .switchToLatest() + .removeDuplicates { previous, next in + previous.0 == next.0 && previous.1 == next.1 + } + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + Task { @MainActor [weak self] in + self?.directoryChangeGeneration &+= 1 + } + } + } +} + struct ContentView: View { @ObservedObject var updateViewModel: UpdateViewModel let windowId: UUID @@ -1900,6 +1933,7 @@ struct ContentView: View { @StateObject private var fullscreenControlsViewModel = TitlebarControlsViewModel() @StateObject private var fileExplorerStore = FileExplorerStore() @StateObject private var sessionIndexStore = SessionIndexStore() + @StateObject private var selectedWorkspaceDirectoryObserver = SelectedWorkspaceDirectoryObserver() @State private var fileExplorerWidth: CGFloat = 220 @State private var fileExplorerDragStartWidth: CGFloat? @State private var previousSelectedWorkspaceId: UUID? @@ -3074,21 +3108,21 @@ struct ContentView: View { let tab = tabManager.tabs.first(where: { $0.id == selectedId }) else { // No selection means we have no local cwd to scope by; clear so the // sessions panel doesn't keep filtering by a stale previous tab. - sessionIndexStore.currentDirectory = nil + sessionIndexStore.setCurrentDirectoryIfChanged(nil) return } let dir = tab.currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines) guard !dir.isEmpty else { - sessionIndexStore.currentDirectory = nil + sessionIndexStore.setCurrentDirectoryIfChanged(nil) return } fileExplorerStore.showHiddenFiles = true if !tab.isRemoteWorkspace { - sessionIndexStore.currentDirectory = dir + sessionIndexStore.setCurrentDirectoryIfChanged(dir) } else { - sessionIndexStore.currentDirectory = nil + sessionIndexStore.setCurrentDirectoryIfChanged(nil) } if tab.isRemoteWorkspace { @@ -3215,6 +3249,7 @@ struct ContentView: View { ) view = AnyView(view.onAppear { + selectedWorkspaceDirectoryObserver.wire(tabManager: tabManager) tabManager.applyWindowBackgroundForSelectedTab() reconcileMountedWorkspaceIds() previousSelectedWorkspaceId = tabManager.selectedTabId @@ -3309,21 +3344,8 @@ struct ContentView: View { syncSidebarSelectedWorkspaceIds() }) - // File explorer: reactively sync CWD when selected workspace or its directory changes. - // Uses switchToLatest to automatically unsubscribe from the old workspace's publisher. - view = AnyView(view.onReceive( - tabManager.$selectedTabId - .compactMap { [weak tabManager] tabId -> Workspace? in - guard let tabId, let tabManager else { return nil } - return tabManager.tabs.first(where: { $0.id == tabId }) - } - .map { workspace -> AnyPublisher in - workspace.$currentDirectory.eraseToAnyPublisher() - } - .switchToLatest() - .removeDuplicates() - .receive(on: DispatchQueue.main) - ) { _ in + // File explorer: keep the Combine subscription stable across body re-evaluations. + view = AnyView(view.onChange(of: selectedWorkspaceDirectoryObserver.directoryChangeGeneration) { _ in syncFileExplorerDirectory() }) diff --git a/Sources/SessionIndexStore.swift b/Sources/SessionIndexStore.swift index c2e654b0b6a6..bdebb5146c9f 100644 --- a/Sources/SessionIndexStore.swift +++ b/Sources/SessionIndexStore.swift @@ -291,6 +291,11 @@ final class SessionIndexStore: ObservableObject { } } + func setCurrentDirectoryIfChanged(_ next: String?) { + guard currentDirectory != next else { return } + currentDirectory = next + } + @Published var grouping: SessionGrouping { didSet { guard grouping != oldValue else { return } diff --git a/cmuxTests/SessionIndexViewTests.swift b/cmuxTests/SessionIndexViewTests.swift index c1740fffd1a3..954e4b4eb94f 100644 --- a/cmuxTests/SessionIndexViewTests.swift +++ b/cmuxTests/SessionIndexViewTests.swift @@ -19,8 +19,8 @@ final class SessionIndexViewTests: XCTestCase { .sink { emittedValues.append($0) } defer { cancellable.cancel() } - store.currentDirectory = "/foo" - store.currentDirectory = "/foo" + store.setCurrentDirectoryIfChanged("/foo") + store.setCurrentDirectoryIfChanged("/foo") XCTAssertEqual(emittedValues, ["/foo"]) } From 5cc98c477c4c16edf4056d716611521735a225be Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 19 Apr 2026 19:44:59 -0700 Subject: [PATCH 3/4] Address directory observer review comments --- Sources/ContentView.swift | 17 +++++++++-------- Sources/RightSidebarPanelView.swift | 12 ++++++------ 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 3460cd2a48b1..2e15dfafcc06 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1888,14 +1888,17 @@ private final class SelectedWorkspaceDirectoryObserver: ObservableObject { guard self.tabManager !== tabManager || cancellable == nil else { return } self.tabManager = tabManager cancellable = tabManager.$selectedTabId - .compactMap { [weak tabManager] tabId -> Workspace? in + .map { [weak tabManager] tabId -> Workspace? in guard let tabId, let tabManager else { return nil } return tabManager.tabs.first(where: { $0.id == tabId }) } - .removeDuplicates(by: { $0.id == $1.id }) - .map { workspace -> AnyPublisher<(UUID, String), Never> in - workspace.$currentDirectory - .map { (workspace.id, $0) } + .removeDuplicates(by: { $0?.id == $1?.id }) + .map { workspace -> AnyPublisher<(UUID?, String?), Never> in + guard let workspace else { + return Just<(UUID?, String?)>((nil, nil)).eraseToAnyPublisher() + } + return workspace.$currentDirectory + .map { (Optional(workspace.id), Optional($0)) } .eraseToAnyPublisher() } .switchToLatest() @@ -1904,9 +1907,7 @@ private final class SelectedWorkspaceDirectoryObserver: ObservableObject { } .receive(on: DispatchQueue.main) .sink { [weak self] _ in - Task { @MainActor [weak self] in - self?.directoryChangeGeneration &+= 1 - } + self?.directoryChangeGeneration &+= 1 } } } diff --git a/Sources/RightSidebarPanelView.swift b/Sources/RightSidebarPanelView.swift index c5feec908e83..568f2e6840e5 100644 --- a/Sources/RightSidebarPanelView.swift +++ b/Sources/RightSidebarPanelView.swift @@ -47,9 +47,7 @@ struct RightSidebarPanelView: View { if fileExplorerState.mode != mode { fileExplorerState.mode = mode if mode == .sessions { - sessionIndexStore.currentDirectory = fileExplorerStore.rootPath.isEmpty - ? nil - : fileExplorerStore.rootPath + sessionIndexStore.setCurrentDirectoryIfChanged(sessionIndexDirectory) if sessionIndexStore.entries.isEmpty { sessionIndexStore.reload() } @@ -73,12 +71,14 @@ struct RightSidebarPanelView: View { case .sessions: SessionIndexView(store: sessionIndexStore, onResume: onResumeSession) .onAppear { - sessionIndexStore.currentDirectory = fileExplorerStore.rootPath.isEmpty - ? nil - : fileExplorerStore.rootPath + sessionIndexStore.setCurrentDirectoryIfChanged(sessionIndexDirectory) } } } + + private var sessionIndexDirectory: String? { + fileExplorerStore.rootPath.isEmpty ? nil : fileExplorerStore.rootPath + } } private struct ModeBarButton: View { From c3466936be1501bd18a2b3d0c93a7d1fcb58c4e9 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 19 Apr 2026 21:30:14 -0700 Subject: [PATCH 4/4] Fix SessionIndexView test harness section initializer --- cmuxTests/SessionIndexViewTests.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmuxTests/SessionIndexViewTests.swift b/cmuxTests/SessionIndexViewTests.swift index 954e4b4eb94f..0b80332ae958 100644 --- a/cmuxTests/SessionIndexViewTests.swift +++ b/cmuxTests/SessionIndexViewTests.swift @@ -102,7 +102,8 @@ final class SessionIndexViewTests: XCTestCase { let section = IndexSection( key: .directory("/tmp"), title: "tmp", - icon: .folder + icon: .folder, + entries: [] ) let search: SessionSearchFn = { _, _, _, _ in SessionIndexStore.SearchOutcome(entries: [], errors: [])