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 = ""
}
/// 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 {
@Environment(\.dismiss) var dismiss
@Bindable private var settingsService = SettingsService.shared
@@ -49,6 +90,7 @@ struct SettingsView: View {
@State private var googleKey = ""
@State private var googleEngineID = ""
@State private var selectedTab = 0
@State private var selectedMCPSubsection: MCPSubsection = .fileSystem
@State private var isFolderDropTargeted = false
@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()
// 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 {
VStack(alignment: .leading, spacing: 20) {
switch selectedTab {
case 0:
generalTab
case 1:
mcpTab
case 2:
appearanceTab
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(.vertical, 16)
}
}
}
.frame(minWidth: 860, idealWidth: 940, minHeight: 620, idealHeight: 760)
.frame(minWidth: 900, idealWidth: 1000, minHeight: 620, idealHeight: 760)
.sheet(isPresented: $showDefaultModelPicker) {
ModelSelectorView(
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
@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
VStack(alignment: .leading, 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)
}
// MARK: Bash Execution
Divider()
@ViewBuilder
private var bashExecutionSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
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)
}
}
}
// MARK: Research Agents
Divider()
@ViewBuilder
private var researchAgentsSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
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
Divider()
externalMCPSection
// MARK: CLI Access
Divider()
cliServerSection
// MARK: Personal Data
@ViewBuilder
private var personalDataSection: some View {
if !PersonalDataTools.isHiddenPendingAppleFix {
Divider()
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
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)
.onAppear {
// 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.
calendarAccessState = EventKitService.shared.calendarAccessState
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)
}
}
}
// MARK: Mail
@ViewBuilder
private var mailSection: some View {
// 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
// feature_mail_applescript_integration in memory. Flip the switch back once fixed.
if !MailTools.isHiddenPendingAppleFix {
Divider()
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Image(systemName: "envelope.badge")