Split the MCP Settings tab into a sidebar-navigated set of sub-pages

The MCP tab had grown into one long scrolling list mixing seven unrelated
areas (File System, Bash Execution, Research Agents, External MCP
Servers, CLI Access, Personal Data, Mail), making it hard to find
anything. Split into a left sidebar (mirroring the existing top-bar
tabButton's blue-accent selected style, just as left-aligned icon+label
rows instead of icon-over-label) with each area now its own standalone
page.

The MCP tab renders outside the shared Settings ScrollView so its
content pane can scroll independently while the sidebar stays pinned —
nesting a plain ScrollView inside another one without an explicit height
just sizes to content rather than scrolling on its own. All 11 other
tabs are unaffected. Bumped the Settings window's min/ideal width
slightly to give the content pane room now that the sidebar takes some
of it on the MCP tab specifically.

Personal Data's and Mail's existing kill-switch guards
(PersonalDataTools/MailTools.isHiddenPendingAppleFix) now also hide
their sidebar rows entirely via MCPSubsection.visibleCases, not just
their content.
This commit is contained in:
2026-08-20 08:24:47 +02:00
parent 5787dee018
commit b8e1986cdb
+115 -25
View File
@@ -32,6 +32,47 @@ private struct MCPKeyValuePair: Identifiable {
var value: String = "" var value: String = ""
} }
/// The MCP tab's sidebar-navigated sub-pages. `visibleCases` hides Personal Data/Mail while
/// their respective kill switches (`PersonalDataTools`/`MailTools.isHiddenPendingAppleFix`) are
/// active, mirroring the same gating those sections' own content already enforces.
private enum MCPSubsection: String, CaseIterable, Identifiable {
case fileSystem, bash, researchAgents, externalMCP, cliAccess, personalData, mail
var id: String { rawValue }
var label: LocalizedStringKey {
switch self {
case .fileSystem: return "File System"
case .bash: return "Bash Execution"
case .researchAgents: return "Research Agents"
case .externalMCP: return "External MCP"
case .cliAccess: return "CLI Access"
case .personalData: return "Personal Data"
case .mail: return "Mail"
}
}
var icon: String {
switch self {
case .fileSystem: return "folder.badge.gearshape"
case .bash: return "terminal.fill"
case .researchAgents: return "person.3.fill"
case .externalMCP: return "server.rack"
case .cliAccess: return "terminal"
case .personalData: return "person.crop.circle.badge.checkmark"
case .mail: return "envelope.badge"
}
}
static var visibleCases: [MCPSubsection] {
allCases.filter { section in
if section == .personalData { return !PersonalDataTools.isHiddenPendingAppleFix }
if section == .mail { return !MailTools.isHiddenPendingAppleFix }
return true
}
}
}
struct SettingsView: View { struct SettingsView: View {
@Environment(\.dismiss) var dismiss @Environment(\.dismiss) var dismiss
@Bindable private var settingsService = SettingsService.shared @Bindable private var settingsService = SettingsService.shared
@@ -49,6 +90,7 @@ struct SettingsView: View {
@State private var googleKey = "" @State private var googleKey = ""
@State private var googleEngineID = "" @State private var googleEngineID = ""
@State private var selectedTab = 0 @State private var selectedTab = 0
@State private var selectedMCPSubsection: MCPSubsection = .fileSystem
@State private var isFolderDropTargeted = false @State private var isFolderDropTargeted = false
@State private var logLevel: LogLevel = FileLogger.shared.minimumLevel @State private var logLevel: LogLevel = FileLogger.shared.minimumLevel
@@ -206,13 +248,18 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
Divider() Divider()
// MCP gets its own sidebar-navigated layout outside the shared ScrollView below
// nesting a plain ScrollView inside another ScrollView without an explicit height
// just sizes to content instead of scrolling independently, so its content pane
// needs to own its own top-level scroll region for the sidebar to stay pinned.
if selectedTab == 1 {
mcpTabWithSidebar
} else {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 20) {
switch selectedTab { switch selectedTab {
case 0: case 0:
generalTab generalTab
case 1:
mcpTab
case 2: case 2:
appearanceTab appearanceTab
case 3: case 3:
@@ -240,9 +287,10 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
.padding(.horizontal, 24) .padding(.horizontal, 24)
.padding(.vertical, 16) .padding(.vertical, 16)
} }
}
} }
.frame(minWidth: 860, idealWidth: 940, minHeight: 620, idealHeight: 760) .frame(minWidth: 900, idealWidth: 1000, minHeight: 620, idealHeight: 760)
.sheet(isPresented: $showDefaultModelPicker) { .sheet(isPresented: $showDefaultModelPicker) {
ModelSelectorView( ModelSelectorView(
models: chatViewModel?.availableModels ?? [], models: chatViewModel?.availableModels ?? [],
@@ -585,7 +633,57 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
// MARK: - MCP Tab // MARK: - MCP Tab
@ViewBuilder @ViewBuilder
private var mcpTab: some View { private func mcpSidebarRow(_ section: MCPSubsection) -> some View {
Button(action: { selectedMCPSubsection = section }) {
HStack(spacing: 10) {
Image(systemName: section.icon)
.font(.system(size: 14))
.frame(width: 18)
Text(section.label)
.font(.system(size: 13))
Spacer()
}
.padding(.horizontal, 10)
.padding(.vertical, 7)
.background(selectedMCPSubsection == section ? Color.blue.opacity(0.1) : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
.foregroundStyle(selectedMCPSubsection == section ? .blue : .primary)
}
@ViewBuilder
private var mcpTabWithSidebar: some View {
HStack(alignment: .top, spacing: 0) {
VStack(alignment: .leading, spacing: 2) {
ForEach(MCPSubsection.visibleCases) { mcpSidebarRow($0) }
Spacer()
}
.padding(10)
.frame(width: 180)
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 20) {
switch selectedMCPSubsection {
case .fileSystem: fileSystemSection
case .bash: bashExecutionSection
case .researchAgents: researchAgentsSection
case .externalMCP: externalMCPSection
case .cliAccess: cliServerSection
case .personalData: personalDataSection
case .mail: mailSection
}
}
.padding(.horizontal, 24)
.padding(.vertical, 16)
}
}
}
@ViewBuilder
private var fileSystemSection: some View {
// Description header // Description header
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) { HStack(spacing: 8) {
@@ -789,10 +887,10 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
} }
// Anytype integration UI hidden (work in progress see AnytypeMCPService.swift) // Anytype integration UI hidden (work in progress see AnytypeMCPService.swift)
}
// MARK: Bash Execution @ViewBuilder
Divider() private var bashExecutionSection: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) { HStack(spacing: 8) {
Image(systemName: "terminal.fill") Image(systemName: "terminal.fill")
@@ -882,10 +980,10 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
.padding(.horizontal, 4) .padding(.horizontal, 4)
} }
} }
}
// MARK: Research Agents @ViewBuilder
Divider() private var researchAgentsSection: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) { HStack(spacing: 8) {
Image(systemName: "person.3.fill") Image(systemName: "person.3.fill")
@@ -940,19 +1038,11 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
} }
} }
} }
}
// MARK: External MCP Servers @ViewBuilder
Divider() private var personalDataSection: some View {
externalMCPSection
// MARK: CLI Access
Divider()
cliServerSection
// MARK: Personal Data
if !PersonalDataTools.isHiddenPendingAppleFix { if !PersonalDataTools.isHiddenPendingAppleFix {
Divider()
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) { HStack(spacing: 8) {
Image(systemName: "person.crop.circle.badge.checkmark") Image(systemName: "person.crop.circle.badge.checkmark")
@@ -969,7 +1059,7 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
.padding(.bottom, 4) .padding(.bottom, 4)
.onAppear { .onAppear {
// Permission status can change outside the app (System Settings, or a prior // Permission status can change outside the app (System Settings, or a prior
// request elsewhere) re-read it fresh every time this tab appears rather than // request elsewhere) re-read it fresh every time this page appears rather than
// trusting the one-time @State initializer. // trusting the one-time @State initializer.
calendarAccessState = EventKitService.shared.calendarAccessState calendarAccessState = EventKitService.shared.calendarAccessState
remindersAccessState = EventKitService.shared.reminderAccessState remindersAccessState = EventKitService.shared.reminderAccessState
@@ -1031,14 +1121,14 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
.padding(.horizontal, 4) .padding(.horizontal, 4)
} }
} }
}
// MARK: Mail @ViewBuilder
private var mailSection: some View {
// Hidden while MailTools.isHiddenPendingAppleFix is true blocked by a confirmed macOS 27 // Hidden while MailTools.isHiddenPendingAppleFix is true blocked by a confirmed macOS 27
// beta bug (first-time Automation consent grants never work), not an app bug. See // beta bug (first-time Automation consent grants never work), not an app bug. See
// feature_mail_applescript_integration in memory. Flip the switch back once fixed. // feature_mail_applescript_integration in memory. Flip the switch back once fixed.
if !MailTools.isHiddenPendingAppleFix { if !MailTools.isHiddenPendingAppleFix {
Divider()
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) { HStack(spacing: 8) {
Image(systemName: "envelope.badge") Image(systemName: "envelope.badge")