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
+144 -54
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,43 +248,49 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
Divider() Divider()
ScrollView { // MCP gets its own sidebar-navigated layout outside the shared ScrollView below
VStack(alignment: .leading, spacing: 20) { // nesting a plain ScrollView inside another ScrollView without an explicit height
switch selectedTab { // just sizes to content instead of scrolling independently, so its content pane
case 0: // needs to own its own top-level scroll region for the sidebar to stay pinned.
generalTab if selectedTab == 1 {
case 1: mcpTabWithSidebar
mcpTab } else {
case 2: ScrollView {
appearanceTab VStack(alignment: .leading, spacing: 20) {
case 3: switch selectedTab {
advancedTab case 0:
case 4: generalTab
syncTab case 2:
case 5: appearanceTab
emailTab case 3:
case 6: advancedTab
shortcutsTab case 4:
case 7: syncTab
agentSkillsTab case 5:
case 8: emailTab
paperlessTab case 6:
case 9: shortcutsTab
backupTab case 7:
case 10: agentSkillsTab
anytypeTab case 8:
case 11: paperlessTab
jarvisTab case 9:
default: backupTab
generalTab case 10:
anytypeTab
case 11:
jarvisTab
default:
generalTab
}
} }
.padding(.horizontal, 24)
.padding(.vertical, 16)
} }
.padding(.horizontal, 24)
.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")