Skip to content

Commit b250a1c

Browse files
authored
feat(macos): registry-info popup on badge click; drop configured-registries panel (#589)
In the macOS Registries/browse view: - Remove the bottom "Configured registries" description panel. The registry list now lives in the browse multiselect and the per-result badge popup. The Add-Registry affordance is kept; load errors surface via an inline banner instead of the removed panel. - Make the per-result registry badge tappable. Clicking it opens a sheet showing the registry's name, provenance badge, description and URL, looked up from the loaded registries by id then name (case-insensitive). Falls back to the raw label when nothing matches; custom/unverified registries show the always-quarantined note. Adds Registry.lookup(_:in:) with unit tests. Related: MCP-1050
1 parent 1a24cbb commit b250a1c

4 files changed

Lines changed: 165 additions & 117 deletions

File tree

native/macos/MCPProxy/MCPProxy/API/RegistryModels.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,17 @@ struct Registry: Codable, Identifiable, Equatable {
4545
var isCustom: Bool {
4646
provenance == RegistryProvenance.custom || trusted == false
4747
}
48+
49+
/// Resolve the full `Registry` that a search result's `registry` field
50+
/// refers to. That field may carry the registry id OR its display name (the
51+
/// backend search response uses the name — see `RepositoryServer.registry`),
52+
/// so match on id first, then fall back to a case-insensitive name match.
53+
/// Returns nil when nothing matches; the badge popup then shows the raw
54+
/// label only. Used by the macOS browse view (MCP-1050).
55+
static func lookup(_ nameOrID: String, in registries: [Registry]) -> Registry? {
56+
if let byID = registries.first(where: { $0.id == nameOrID }) { return byID }
57+
return registries.first { $0.name.caseInsensitiveCompare(nameOrID) == .orderedSame }
58+
}
4859
}
4960

5061
/// Slim projection returned by `POST /api/v1/registries` (add-source).

native/macos/MCPProxy/MCPProxy/Views/RegistriesView.swift

Lines changed: 6 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -48,21 +48,15 @@ struct RegistriesView: View {
4848
if let success = successMessage {
4949
banner(icon: "checkmark.circle.fill", tint: .green, text: success)
5050
}
51+
if let err = loadError {
52+
banner(icon: "exclamationmark.triangle.fill", tint: .orange, text: err)
53+
}
5154

5255
// Browse + add servers across one or more registries (R1 parity).
56+
// The configured-registries list lives in the browse multiselect and
57+
// the per-result registry badge → info popup (MCP-1050); there is no
58+
// longer a bottom "Configured registries" description panel.
5359
ServerBrowseView(appState: appState, registries: registries)
54-
55-
HStack {
56-
Text("Configured registries")
57-
.font(.scaled(.caption, scale: fontScale).bold())
58-
.foregroundStyle(.secondary)
59-
Spacer()
60-
}
61-
.padding(.horizontal)
62-
.padding(.top, 4)
63-
Divider()
64-
65-
content
6660
}
6761
.sheet(isPresented: $showAddRegistry) {
6862
AddRegistryView(appState: appState, isPresented: $showAddRegistry) { added in
@@ -102,109 +96,6 @@ struct RegistriesView: View {
10296

10397
// MARK: Content
10498

105-
@ViewBuilder
106-
private var content: some View {
107-
if let err = loadError, registries.isEmpty {
108-
VStack(spacing: 12) {
109-
Spacer()
110-
Image(systemName: "exclamationmark.triangle.fill")
111-
.font(.system(size: 40 * fontScale))
112-
.foregroundStyle(.orange)
113-
Text("Couldn't load registries")
114-
.font(.scaled(.title3, scale: fontScale))
115-
.foregroundStyle(.secondary)
116-
Text(err)
117-
.font(.scaled(.caption, scale: fontScale))
118-
.foregroundStyle(.tertiary)
119-
.multilineTextAlignment(.center)
120-
Spacer()
121-
}
122-
.frame(maxWidth: .infinity, maxHeight: .infinity)
123-
.padding()
124-
} else if registries.isEmpty && !isLoading {
125-
VStack(spacing: 12) {
126-
Spacer()
127-
Image(systemName: "books.vertical")
128-
.font(.system(size: 40 * fontScale))
129-
.foregroundStyle(.tertiary)
130-
Text("No registries")
131-
.font(.scaled(.title3, scale: fontScale))
132-
.foregroundStyle(.secondary)
133-
Spacer()
134-
}
135-
.frame(maxWidth: .infinity, maxHeight: .infinity)
136-
} else {
137-
ScrollView {
138-
VStack(alignment: .leading, spacing: 8) {
139-
ForEach(registries) { registry in
140-
registryRow(registry)
141-
}
142-
}
143-
.padding()
144-
}
145-
}
146-
}
147-
148-
@ViewBuilder
149-
private func registryRow(_ registry: Registry) -> some View {
150-
VStack(alignment: .leading, spacing: 4) {
151-
HStack(spacing: 8) {
152-
Text(registry.name.isEmpty ? registry.id : registry.name)
153-
.font(.scaled(.headline, scale: fontScale))
154-
provenanceBadge(registry)
155-
Spacer()
156-
}
157-
158-
if let desc = registry.description, !desc.isEmpty {
159-
Text(desc)
160-
.font(.scaled(.caption, scale: fontScale))
161-
.foregroundStyle(.secondary)
162-
}
163-
164-
if let url = registry.serversURL ?? registry.url, !url.isEmpty {
165-
Text(url)
166-
.font(.scaledMonospaced(.caption, scale: fontScale))
167-
.foregroundStyle(.tertiary)
168-
.lineLimit(1)
169-
.truncationMode(.middle)
170-
}
171-
172-
if registry.isCustom {
173-
Text("Servers added from this third-party registry are always quarantined and cannot skip security review.")
174-
.font(.scaled(.caption2, scale: fontScale))
175-
.foregroundStyle(.orange)
176-
.accessibilityIdentifier("registry-custom-quarantine-note")
177-
}
178-
}
179-
.padding(10)
180-
.frame(maxWidth: .infinity, alignment: .leading)
181-
.background(Color(nsColor: .controlBackgroundColor))
182-
.cornerRadius(8)
183-
.accessibilityIdentifier("registry-row-\(registry.id)")
184-
}
185-
186-
@ViewBuilder
187-
private func provenanceBadge(_ registry: Registry) -> some View {
188-
if registry.isCustom {
189-
badge(text: "Third-party \u{00B7} unverified", tint: .orange)
190-
.accessibilityIdentifier("registry-provenance-badge-custom")
191-
} else {
192-
badge(text: "Official \u{00B7} trusted", tint: .green)
193-
.accessibilityIdentifier("registry-provenance-badge-official")
194-
}
195-
}
196-
197-
@ViewBuilder
198-
private func badge(text: String, tint: Color) -> some View {
199-
Text(text)
200-
.font(.scaled(.caption2, scale: fontScale).weight(.medium))
201-
.padding(.horizontal, 6)
202-
.padding(.vertical, 2)
203-
.background(tint.opacity(0.18))
204-
.foregroundStyle(tint)
205-
.clipShape(Capsule())
206-
}
207-
20899
@ViewBuilder
209100
private func banner(icon: String, tint: Color, text: String) -> some View {
210101
HStack {

native/macos/MCPProxy/MCPProxy/Views/ServerBrowseView.swift

Lines changed: 112 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,19 @@ struct ServerBrowseView: View {
2525
@State private var searchError: String?
2626
@State private var addingID: String?
2727
@State private var addNote: String?
28+
@State private var registryInfo: RegistryInfoContext?
2829

2930
private var apiClient: APIClient? { appState.apiClient }
3031

32+
/// Identifiable context for the registry-info popup (`.sheet(item:)`). Holds
33+
/// the looked-up `Registry` (nil when the result's label matched nothing in
34+
/// the loaded list) plus the raw `label` so the popup always has a title.
35+
struct RegistryInfoContext: Identifiable {
36+
let id = UUID()
37+
let registry: Registry?
38+
let label: String
39+
}
40+
3141
private func registryName(_ id: String) -> String {
3242
registries.first { $0.id == id }?.name ?? id
3343
}
@@ -64,6 +74,93 @@ struct ServerBrowseView: View {
6474
resultsArea
6575
}
6676
.accessibilityIdentifier("server-browse")
77+
.sheet(item: $registryInfo) { ctx in
78+
registryInfoSheet(ctx)
79+
}
80+
}
81+
82+
// MARK: Registry-info popup (MCP-1050)
83+
84+
@ViewBuilder
85+
private func registryInfoSheet(_ ctx: RegistryInfoContext) -> some View {
86+
let displayName = ctx.registry.map { $0.name.isEmpty ? $0.id : $0.name } ?? ctx.label
87+
VStack(alignment: .leading, spacing: 14) {
88+
HStack(spacing: 8) {
89+
Text(displayName)
90+
.font(.scaled(.title3, scale: fontScale).bold())
91+
if let reg = ctx.registry { provenanceBadge(reg) }
92+
Spacer()
93+
Button {
94+
registryInfo = nil
95+
} label: {
96+
Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary)
97+
}
98+
.buttonStyle(.borderless)
99+
.accessibilityIdentifier("registry-info-close")
100+
}
101+
102+
if let reg = ctx.registry {
103+
if let desc = reg.description, !desc.isEmpty {
104+
Text(desc)
105+
.font(.scaled(.subheadline, scale: fontScale))
106+
.foregroundStyle(.secondary)
107+
.fixedSize(horizontal: false, vertical: true)
108+
}
109+
110+
if let url = reg.serversURL ?? reg.url, !url.isEmpty {
111+
VStack(alignment: .leading, spacing: 2) {
112+
Text("URL")
113+
.font(.scaled(.caption2, scale: fontScale))
114+
.foregroundStyle(.secondary)
115+
Text(url)
116+
.font(.scaledMonospaced(.caption, scale: fontScale))
117+
.foregroundStyle(.tertiary)
118+
.textSelection(.enabled)
119+
.lineLimit(2)
120+
.truncationMode(.middle)
121+
}
122+
}
123+
124+
if reg.isCustom {
125+
Text("Servers added from this third-party registry are always quarantined and cannot skip security review.")
126+
.font(.scaled(.caption, scale: fontScale))
127+
.foregroundStyle(.orange)
128+
.fixedSize(horizontal: false, vertical: true)
129+
.accessibilityIdentifier("registry-info-quarantine-note")
130+
}
131+
} else {
132+
Text("No additional details are available for this registry.")
133+
.font(.scaled(.subheadline, scale: fontScale))
134+
.foregroundStyle(.secondary)
135+
}
136+
137+
Spacer(minLength: 0)
138+
}
139+
.padding()
140+
.frame(width: 420, height: 220)
141+
.accessibilityIdentifier("registry-info-popup")
142+
}
143+
144+
@ViewBuilder
145+
private func provenanceBadge(_ registry: Registry) -> some View {
146+
if registry.isCustom {
147+
badge(text: "Third-party \u{00B7} unverified", tint: .orange)
148+
.accessibilityIdentifier("registry-info-badge-custom")
149+
} else {
150+
badge(text: "Official \u{00B7} trusted", tint: .green)
151+
.accessibilityIdentifier("registry-info-badge-official")
152+
}
153+
}
154+
155+
@ViewBuilder
156+
private func badge(text: String, tint: Color) -> some View {
157+
Text(text)
158+
.font(.scaled(.caption2, scale: fontScale).weight(.medium))
159+
.padding(.horizontal, 6)
160+
.padding(.vertical, 2)
161+
.background(tint.opacity(0.18))
162+
.foregroundStyle(tint)
163+
.clipShape(Capsule())
67164
}
68165

69166
// MARK: Filter bar (multiselect + search)
@@ -167,12 +264,25 @@ struct ServerBrowseView: View {
167264
.fixedSize(horizontal: false, vertical: true)
168265
Spacer()
169266
if let reg = server.registry, !reg.isEmpty {
170-
Text(reg)
267+
// Tappable: opens an info popup for the originating registry
268+
// (name/url/description/provenance) — MCP-1050.
269+
Button {
270+
registryInfo = RegistryInfoContext(
271+
registry: Registry.lookup(reg, in: registries), label: reg)
272+
} label: {
273+
HStack(spacing: 3) {
274+
Text(reg)
275+
Image(systemName: "info.circle")
276+
.font(.system(size: 9 * fontScale))
277+
}
171278
.font(.scaled(.caption2, scale: fontScale))
172279
.padding(.horizontal, 6).padding(.vertical, 2)
173280
.background(Color.secondary.opacity(0.15))
174281
.clipShape(Capsule())
175-
.accessibilityIdentifier("browse-source-\(server.id)")
282+
}
283+
.buttonStyle(.plain)
284+
.help("Show registry info")
285+
.accessibilityIdentifier("browse-source-\(server.id)")
176286
}
177287
}
178288
if let desc = server.description, !desc.isEmpty {

native/macos/MCPProxy/MCPProxyTests/RegistryModelsTests.swift

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,42 @@ final class RegistryModelsTests: XCTestCase {
139139
)
140140
}
141141

142+
// MARK: - Registry lookup by name-or-id (MCP-1050 badge → info popup)
143+
144+
private func sampleRegistries() throws -> [Registry] {
145+
let json = """
146+
{"registries":[
147+
{"id":"official","name":"Official MCP Registry",
148+
"description":"Primary aggregator","url":"https://registry.modelcontextprotocol.io",
149+
"provenance":"official/trusted","trusted":true},
150+
{"id":"acme","name":"Acme","provenance":"custom/unverified","trusted":false}
151+
],"total":2}
152+
"""
153+
return try decode(GetRegistriesResponse.self, from: json).registries
154+
}
155+
156+
func testLookupMatchesByID() throws {
157+
let regs = try sampleRegistries()
158+
XCTAssertEqual(Registry.lookup("official", in: regs)?.id, "official")
159+
}
160+
161+
func testLookupFallsBackToName() throws {
162+
// A search result's `registry` field carries the registry *name*, so a
163+
// name match must resolve when no id matches.
164+
let regs = try sampleRegistries()
165+
XCTAssertEqual(Registry.lookup("Acme", in: regs)?.id, "acme")
166+
}
167+
168+
func testLookupNameIsCaseInsensitive() throws {
169+
let regs = try sampleRegistries()
170+
XCTAssertEqual(Registry.lookup("ACME", in: regs)?.id, "acme")
171+
}
172+
173+
func testLookupReturnsNilWhenNotFound() throws {
174+
let regs = try sampleRegistries()
175+
XCTAssertNil(Registry.lookup("nonexistent", in: regs))
176+
}
177+
142178
// MARK: - One-time third-party warning ack persistence (mirrors localStorage)
143179

144180
func testThirdPartyAckPersistence() throws {

0 commit comments

Comments
 (0)