diff --git a/oAI/Views/Screens/SettingsView.swift b/oAI/Views/Screens/SettingsView.swift index d17efec..9ee0c64 100644 --- a/oAI/Views/Screens/SettingsView.swift +++ b/oAI/Views/Screens/SettingsView.swift @@ -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,43 +248,49 @@ It's better to admit "I need more information" or "I cannot do that" than to fak Divider() - ScrollView { - VStack(alignment: .leading, spacing: 20) { - switch selectedTab { - case 0: - generalTab - case 1: - mcpTab - case 2: - appearanceTab - case 3: - advancedTab - case 4: - syncTab - case 5: - emailTab - case 6: - shortcutsTab - case 7: - agentSkillsTab - case 8: - paperlessTab - case 9: - backupTab - case 10: - anytypeTab - case 11: - jarvisTab - default: - generalTab + // 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 2: + appearanceTab + case 3: + advancedTab + case 4: + syncTab + case 5: + emailTab + case 6: + shortcutsTab + case 7: + agentSkillsTab + case 8: + paperlessTab + case 9: + backupTab + 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) { 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")