diff --git a/native/macos/MCPProxy/MCPProxy/Settings/ConfigSettingsView.swift b/native/macos/MCPProxy/MCPProxy/Settings/ConfigSettingsView.swift index 84e66d214..7ff9dbfd6 100644 --- a/native/macos/MCPProxy/MCPProxy/Settings/ConfigSettingsView.swift +++ b/native/macos/MCPProxy/MCPProxy/Settings/ConfigSettingsView.swift @@ -222,6 +222,12 @@ final class ConfigStore: ObservableObject { func revert(_ keys: [String]) { for k in keys { configSet(&working, k, configGet(original, k)) } + // Discarding the Listen edit ends "the user cleared it": follow the + // running core's address again. + if keys.contains("listen") { + listenCleared = false + adoptRunningListenIfBlank() + } revision += 1 } diff --git a/native/macos/MCPProxy/MCPProxy/Views/ClientsView.swift b/native/macos/MCPProxy/MCPProxy/Views/ClientsView.swift index 66d7a9718..1980dd50f 100644 --- a/native/macos/MCPProxy/MCPProxy/Views/ClientsView.swift +++ b/native/macos/MCPProxy/MCPProxy/Views/ClientsView.swift @@ -129,6 +129,12 @@ struct ClientsView: View { tab = 0 focusedClientID = id pendingExpandID = id + // Clear the clients filter only when it would hide the target; + // the `.onChange` reload keeps `pendingExpandID`, so the row expands. + if Self.filterHidesTarget(filter: clientsFilter, targetID: id, + filteredIDs: nil, roster: appState.clients) { + clientsFilter = ScopeFilter() + } expandPendingClient(dropIfAbsent: false) case .connect(let id)?: tab = 0 @@ -142,6 +148,21 @@ struct ClientsView: View { } } + /// Whether the active clients filter hides the client a `.clientDetail` + /// route targets (a pure decision, so it is unit-testable without a view). + /// `filteredIDs` is the filtered list once loaded (nil before); `roster` is + /// the unfiltered app-wide client list. An unknown target is never "hidden": + /// the request is dropped as before and the filter is left alone. + static func filterHidesTarget(filter: ScopeFilter, targetID: String, + filteredIDs: [String]?, roster: [ClientPresenceRecord]) -> Bool { + guard filter.profile != nil || filter.client != nil else { return false } + guard let target = roster.first(where: { $0.id == targetID }) else { return false } + if let client = filter.client, client != targetID { return true } + if let filteredIDs { return !filteredIDs.contains(targetID) } + if let profile = filter.profile { return target.profile != profile } + return false + } + /// Expand the row a `.clientDetail` route asked for, once it is loaded. /// After a full load the request is dropped when the client is gone. private func expandPendingClient(dropIfAbsent: Bool) { @@ -488,7 +509,14 @@ struct ClientsView: View { let response = try await loadedClients clients = response.clients warnings = response.warnings ?? [] - expandPendingClient(dropIfAbsent: true) + if let id = pendingExpandID, + Self.filterHidesTarget(filter: clientsFilter, targetID: id, + filteredIDs: clients.map(\.id), roster: appState.clients) { + // Filtered out but known: widen the view (the reload re-expands it). + clientsFilter = ScopeFilter() + } else { + expandPendingClient(dropIfAbsent: true) + } if profile == nil && client == nil { appState.clients = response.clients appState.clientWarnings = warnings diff --git a/native/macos/MCPProxy/MCPProxy/Views/ConnectClientModel.swift b/native/macos/MCPProxy/MCPProxy/Views/ConnectClientModel.swift index 1f009d5d2..69d0a7e66 100644 --- a/native/macos/MCPProxy/MCPProxy/Views/ConnectClientModel.swift +++ b/native/macos/MCPProxy/MCPProxy/Views/ConnectClientModel.swift @@ -487,7 +487,11 @@ final class ConnectClientModel: ObservableObject { profiles = updated if !profile.isEmpty, !updated.contains(where: { $0.name == profile }) { profile = "" - lockedChoice = false + lockedChoice = true + // Back to "untouched": the write then sends `.unspecified`, which + // keeps the client's existing binding instead of forcing + // All servers / switchable over it. + bindingTouched = false invalidatePreview() } } diff --git a/native/macos/MCPProxy/MCPProxyTests/ClientsRoutingTests.swift b/native/macos/MCPProxy/MCPProxyTests/ClientsRoutingTests.swift new file mode 100644 index 000000000..3e1722b59 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/ClientsRoutingTests.swift @@ -0,0 +1,59 @@ +// ClientsRoutingTests.swift +// MCPProxyTests +// +// A `.clientDetail` route clears the active clients filter only when that +// filter would hide the target client (wave-2 item 1451-4). + +import XCTest +@testable import MCPProxy + +final class ClientsRoutingTests: XCTestCase { + private func record(_ id: String, profile: String?) -> ClientPresenceRecord { + var r = ClientPresenceRecord( + id: id, displayName: id, kind: "supported", icon: nil, state: "connected_seen", + installed: true, connected: true, connectionUnverified: nil, configPath: nil, + displayPath: nil, lastSeen: nil, activeSessions: 0, calls24h: 0, reloadHint: nil) + r.profile = profile + return r + } + + private var roster: [ClientPresenceRecord] { + [record("cursor", profile: "ro"), record("zed", profile: "rw")] + } + + private func hides(_ filter: ScopeFilter, _ id: String, filtered: [String]? = nil) -> Bool { + ClientsView.filterHidesTarget(filter: filter, targetID: id, filteredIDs: filtered, roster: roster) + } + + func testFilterOnAnotherClientHidesTheTarget() { + var f = ScopeFilter(); f.client = "zed" + XCTAssertTrue(hides(f, "cursor")) + } + + func testFilterOnTheTargetItselfDoesNotHideIt() { + var f = ScopeFilter(); f.client = "cursor" + XCTAssertFalse(hides(f, "cursor")) + } + + func testProfileFilterThatIncludesTheTargetIsKept() { + var f = ScopeFilter(); f.profile = "ro" + XCTAssertFalse(hides(f, "cursor")) + XCTAssertFalse(hides(f, "cursor", filtered: ["cursor"])) + } + + func testProfileFilterThatExcludesTheTargetIsCleared() { + var f = ScopeFilter(); f.profile = "ro" + XCTAssertTrue(hides(f, "zed")) + XCTAssertTrue(hides(f, "zed", filtered: ["cursor"])) + } + + func testUnknownTargetIsNotHidden() { + var f = ScopeFilter(); f.profile = "ro" + XCTAssertFalse(hides(f, "ghost", filtered: [])) + } + + func testNoFilterNeverHides() { + XCTAssertFalse(hides(ScopeFilter(), "cursor")) + XCTAssertFalse(hides(ScopeFilter(), "zed", filtered: [])) + } +} diff --git a/native/macos/MCPProxy/MCPProxyTests/ConnectClientModelTests.swift b/native/macos/MCPProxy/MCPProxyTests/ConnectClientModelTests.swift index 1b2941925..2f529564b 100644 --- a/native/macos/MCPProxy/MCPProxyTests/ConnectClientModelTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/ConnectClientModelTests.swift @@ -1442,6 +1442,17 @@ final class ConnectClientPresentationTests: XCTestCase { XCTAssertEqual(model.mode, .locked) } + func testRefreshProfilesFallbackSendsUnspecifiedSoTheExistingBindingIsKept() async { + let ro = ProfileView(name: "ro", title: "Read-only", servers: ["github"], managementTools: false) + let model = await selectedModel(FakeConnectSource(), profiles: [ro]) + model.chooseProfile("ro") + XCTAssertTrue(model.bindingTouched) + model.refreshProfiles([]) + XCTAssertEqual(model.profile, "") + XCTAssertEqual(model.binding, .unspecified, + "a vanished profile must not turn into an explicit All servers write") + } + func testRefreshProfilesFallsBackToAllServersWhenTheSelectionIsGone() async { let ro = ProfileView(name: "work-ro", title: "Work Read-only", servers: ["github"], managementTools: false) let model = await selectedModel(FakeConnectSource(), profiles: [ro]) diff --git a/native/macos/MCPProxy/MCPProxyTests/SettingsEffectiveStateTests.swift b/native/macos/MCPProxy/MCPProxyTests/SettingsEffectiveStateTests.swift index f399ff920..ef7b9774c 100644 --- a/native/macos/MCPProxy/MCPProxyTests/SettingsEffectiveStateTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/SettingsEffectiveStateTests.swift @@ -105,6 +105,18 @@ final class SettingsEffectiveStateTests: XCTestCase { "a field the user emptied stays empty") } + func testDiscardingAClearedListenReadoptsTheRunningAddress() async throws { + let store = makeStore( + configJSON: #"{"quarantine_enabled":true}"#, + statusJSON: #"{"running":true,"listen_addr":"127.0.0.1:18666"}"#) + await store.load() + store.setValue("listen", "") + store.revert(["listen"]) + store.applyStatus(try status(#"{"running":true,"listen_addr":"127.0.0.1:19777"}"#)) + XCTAssertEqual(store.stringBinding("listen").wrappedValue, "127.0.0.1:19777") + XCTAssertFalse(store.isDirty("listen")) + } + func testTypedListenIsNotMarkedCleanWhenTheCoreRestartsOntoIt() async { let store = makeStore( configJSON: #"{"quarantine_enabled":true}"#,