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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user