Files
scarf/scarf/scarf/Features/MCPServers/Views/MCPServersView.swift
T
Alan Wizemann 8a2d89654b feat(design): adopt ScarfDesign system across Mac UI
Add a typed design-system package (Packages/ScarfDesign) with rust-tone
color tokens, type scale, spacing/radius tokens, ScarfPageHeader and
component primitives (ScarfCard, ScarfBadge, ScarfTextField,
ScarfSectionHeader, ScarfDivider, four button styles). Both Mac and iOS
targets `import ScarfDesign`.

Sidebar redesigned per design/static-site/ui-kit/Sidebar.jsx — glassy
translucent background, 224 px width, app-icon header with server pill,
custom tokenized rows with rust accent-tint when active, footer with
live Hermes-running indicator (wired to ServerLiveStatusRegistry).

14 mockup-backed feature screens redesigned: Settings, Dashboard,
Sessions, Memory, Chat (visual sweep), Activity, Cron, Insights,
MCPServers, Health, Logs, Tools (full); Projects light-touch.
Non-mockup features inherit rust through AccentColor.colorset repoint.

Mac AppIcon.appiconset replaced with the rust set. AccentColor.colorset
repointed to BrandRust hex (light + dark variants).

Visual sweep: every multi-button page-header / action-bar cluster now
wraps in .fixedSize(horizontal: true, vertical: false) so labels can't
wrap letter-by-letter at narrow widths (regression seen on the MCP
detail pane with 4 buttons).

Follow-ups landed:
- Sidebar Hermes-running probe wired to per-window
  ServerLiveStatusRegistry (no more placeholder green).
- Sessions: today filter predicate (isDateInToday(startedAt)); pill
  count reflects real count. Starred stays a no-op pending an upstream
  pinned/starred field on HermesSession.
- Dashboard: Recent activity column rendered alongside Recent sessions
  in a ViewThatFits 2-col grid. Populated from
  HermesDataService.fetchRecentToolCalls(limit:) flattened to
  ActivityEntry. ActivityEntry gains a public memberwise init.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 13:27:54 +02:00

211 lines
7.9 KiB
Swift

import SwiftUI
import ScarfCore
import ScarfDesign
struct MCPServersView: View {
@State private var viewModel: MCPServersViewModel
init(context: ServerContext) {
_viewModel = State(initialValue: MCPServersViewModel(context: context))
}
var body: some View {
VStack(spacing: 0) {
pageHeader
HSplitView {
serversList
.frame(minWidth: 260, idealWidth: 320)
serverDetail
.frame(minWidth: 500)
}
}
.background(ScarfColor.backgroundPrimary)
.navigationTitle("MCP Servers")
.loadingOverlay(
viewModel.isLoading,
label: "Loading MCP servers…",
isEmpty: viewModel.servers.isEmpty
)
.searchable(text: $viewModel.searchText, prompt: "Filter servers...")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
viewModel.load()
} label: {
Label("Reload", systemImage: "arrow.clockwise")
}
}
}
.onAppear { viewModel.load() }
.sheet(isPresented: $viewModel.showPresetPicker) {
MCPServerPresetPickerView(viewModel: viewModel)
}
.sheet(isPresented: $viewModel.showAddCustom) {
MCPServerAddCustomView(viewModel: viewModel)
}
.sheet(isPresented: Binding(
get: { viewModel.editingServer != nil },
set: { if !$0 { viewModel.editingServer = nil } }
)) {
if let server = viewModel.editingServer {
MCPServerEditorView(
viewModel: MCPServerEditorViewModel(server: server),
onSave: { changed in viewModel.finishEdit(reload: changed) },
onCancel: { viewModel.finishEdit(reload: false) }
)
}
}
.alert("Error", isPresented: Binding(
get: { viewModel.activeError != nil },
set: { if !$0 { viewModel.activeError = nil } }
)) {
Button("OK") { viewModel.activeError = nil }
} message: {
Text(viewModel.activeError ?? "")
}
}
private var pageHeader: some View {
HStack(alignment: .top, spacing: ScarfSpace.s3) {
VStack(alignment: .leading, spacing: 2) {
Text("MCP Servers")
.scarfStyle(.title2)
.foregroundStyle(ScarfColor.foregroundPrimary)
Text("Model Context Protocol endpoints — \(viewModel.servers.count) configured.")
.scarfStyle(.footnote)
.foregroundStyle(ScarfColor.foregroundMuted)
}
Spacer()
HStack(spacing: ScarfSpace.s2) {
Button {
viewModel.testAll()
} label: {
Label("Test all", systemImage: "bolt.horizontal")
}
.buttonStyle(ScarfGhostButton())
.disabled(viewModel.servers.isEmpty)
Button {
viewModel.showPresetPicker = true
} label: {
Label("From preset", systemImage: "square.grid.2x2")
}
.buttonStyle(ScarfSecondaryButton())
Button {
viewModel.showAddCustom = true
} label: {
Label("Add server", systemImage: "plus")
}
.buttonStyle(ScarfPrimaryButton())
}
.fixedSize(horizontal: true, vertical: false)
}
.padding(.horizontal, ScarfSpace.s6)
.padding(.top, ScarfSpace.s5)
.padding(.bottom, ScarfSpace.s4)
.overlay(
Rectangle().fill(ScarfColor.border).frame(height: 1),
alignment: .bottom
)
}
private var serversList: some View {
List(selection: Binding(
get: { viewModel.selectedServerName },
set: { viewModel.selectServer(name: $0) }
)) {
if !viewModel.stdioServers.isEmpty {
Section("Local (stdio)") {
ForEach(viewModel.stdioServers) { server in
serverRow(server)
.tag(server.name as String?)
}
}
}
if !viewModel.httpServers.isEmpty {
Section("Remote (HTTP)") {
ForEach(viewModel.httpServers) { server in
serverRow(server)
.tag(server.name as String?)
}
}
}
if viewModel.servers.isEmpty && !viewModel.isLoading {
Section {
Text("No servers configured yet")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.listStyle(.sidebar)
}
@ViewBuilder
private func serverRow(_ server: HermesMCPServer) -> some View {
HStack(spacing: 8) {
Image(systemName: server.transport == .http ? "network" : "terminal")
.foregroundStyle(server.enabled ? ScarfColor.accent : ScarfColor.foregroundMuted)
VStack(alignment: .leading, spacing: 2) {
Text(server.name)
.scarfStyle(.body)
.foregroundStyle(ScarfColor.foregroundPrimary)
if !server.enabled {
Text("Disabled")
.font(ScarfFont.caption2)
.foregroundStyle(ScarfColor.foregroundFaint)
}
}
Spacer()
if viewModel.testingNames.contains(server.name) {
ProgressView().controlSize(.small)
} else if let result = viewModel.testResults[server.name] {
Image(systemName: result.succeeded ? "checkmark.circle.fill" : "xmark.circle.fill")
.foregroundStyle(result.succeeded ? ScarfColor.success : ScarfColor.danger)
.help(result.succeeded ? Text("\(result.tools.count) tools") : Text("Test failed"))
}
}
}
@ViewBuilder
private var serverDetail: some View {
VStack(spacing: 0) {
if viewModel.showRestartBanner {
RestartGatewayBanner(
onRestart: { viewModel.restartGateway() },
onDismiss: { viewModel.showRestartBanner = false }
)
}
if let status = viewModel.statusMessage {
Text(status)
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.accentActive)
.padding(.horizontal, ScarfSpace.s3)
.padding(.vertical, 6)
.frame(maxWidth: .infinity, alignment: .leading)
.background(ScarfColor.accentTint)
}
if let server = viewModel.selectedServer {
MCPServerDetailView(
server: server,
testResult: viewModel.testResults[server.name],
isTesting: viewModel.testingNames.contains(server.name),
onTest: { viewModel.testServer(name: server.name) },
onToggleEnabled: { viewModel.toggleEnabled(name: server.name) },
onEdit: { viewModel.beginEdit() },
onDelete: { viewModel.deleteServer(name: server.name) }
)
} else {
ContentUnavailableView(
"Select an MCP Server",
systemImage: "puzzlepiece.extension",
description: Text("Pick one from the list, or add a new server from the toolbar.")
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
}