From c8433b6c24631308f674c130860d15d4ad14d702 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 29 Mar 2026 14:49:50 +0700 Subject: [PATCH 1/2] feat: add global toggle to disable all AI features --- CHANGELOG.md | 1 + .../Core/AI/InlineSuggestionManager.swift | 1 + TablePro/Core/AI/OllamaDetector.swift | 1 + TablePro/Models/AI/AIModels.swift | 5 ++ TablePro/Resources/Localizable.xcstrings | 22 ++++++++ .../Views/Connection/ConnectionFormView.swift | 16 +++--- .../Views/Editor/AIEditorContextMenu.swift | 2 +- .../MainContentCoordinator+QueryHelpers.swift | 26 ++++++---- .../RightSidebar/UnifiedRightPanelView.swift | 52 ++++++++++++------- TablePro/Views/Settings/AISettingsView.swift | 15 ++++-- 10 files changed, 99 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4336fb81a..805682411 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Global toggle to disable all AI features (Settings > AI) - Drag to reorder columns in the Structure tab (MySQL/MariaDB) - Nested hierarchical groups for connection list (up to 3 levels deep) - Confirmation dialogs for deep link queries, connection imports, and pre-connect scripts diff --git a/TablePro/Core/AI/InlineSuggestionManager.swift b/TablePro/Core/AI/InlineSuggestionManager.swift index 0d658fc64..36ae18eec 100644 --- a/TablePro/Core/AI/InlineSuggestionManager.swift +++ b/TablePro/Core/AI/InlineSuggestionManager.swift @@ -130,6 +130,7 @@ final class InlineSuggestionManager { private func isEnabled() -> Bool { let settings = AppSettingsManager.shared.ai + guard settings.enabled else { return false } guard settings.inlineSuggestEnabled else { return false } guard let controller else { return false } guard let textView = controller.textView else { return false } diff --git a/TablePro/Core/AI/OllamaDetector.swift b/TablePro/Core/AI/OllamaDetector.swift index 1ae055725..3bdbb7600 100644 --- a/TablePro/Core/AI/OllamaDetector.swift +++ b/TablePro/Core/AI/OllamaDetector.swift @@ -16,6 +16,7 @@ enum OllamaDetector { @MainActor static func detectAndRegister() async { let settings = AppSettingsManager.shared.ai + guard settings.enabled else { return } // Skip if an Ollama provider already exists if settings.providers.contains(where: { $0.type == .ollama }) { diff --git a/TablePro/Models/AI/AIModels.swift b/TablePro/Models/AI/AIModels.swift index 8a3262110..4abea7b53 100644 --- a/TablePro/Models/AI/AIModels.swift +++ b/TablePro/Models/AI/AIModels.swift @@ -132,6 +132,7 @@ enum AIConnectionPolicy: String, Codable, CaseIterable, Identifiable { /// Global AI feature settings struct AISettings: Codable, Equatable { + var enabled: Bool var providers: [AIProviderConfig] var featureRouting: [String: AIFeatureRoute] var includeSchema: Bool @@ -142,6 +143,7 @@ struct AISettings: Codable, Equatable { var inlineSuggestEnabled: Bool static let `default` = AISettings( + enabled: true, providers: [], featureRouting: [:], includeSchema: true, @@ -153,6 +155,7 @@ struct AISettings: Codable, Equatable { ) init( + enabled: Bool = true, providers: [AIProviderConfig] = [], featureRouting: [String: AIFeatureRoute] = [:], includeSchema: Bool = true, @@ -162,6 +165,7 @@ struct AISettings: Codable, Equatable { defaultConnectionPolicy: AIConnectionPolicy = .askEachTime, inlineSuggestEnabled: Bool = false ) { + self.enabled = enabled self.providers = providers self.featureRouting = featureRouting self.includeSchema = includeSchema @@ -174,6 +178,7 @@ struct AISettings: Codable, Equatable { init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) + enabled = try container.decodeIfPresent(Bool.self, forKey: .enabled) ?? true providers = try container.decodeIfPresent([AIProviderConfig].self, forKey: .providers) ?? [] featureRouting = try container.decodeIfPresent([String: AIFeatureRoute].self, forKey: .featureRouting) ?? [:] includeSchema = try container.decodeIfPresent(Bool.self, forKey: .includeSchema) ?? true diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 6a4e379cb..305dc23f1 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -11306,6 +11306,28 @@ } } }, + "Enable AI Features" : { + "localizations" : { + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI Özelliklerini Etkinleştir" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bật tính năng AI" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用 AI 功能" + } + } + } + }, "Enable inline suggestions" : { "localizations" : { "tr" : { diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index 2a930e64b..b62d03b91 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -930,13 +930,15 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length .foregroundStyle(.secondary) } - Section(String(localized: "AI")) { - Picker(String(localized: "AI Policy"), selection: $aiPolicy) { - Text(String(localized: "Use Default")) - .tag(AIConnectionPolicy?.none as AIConnectionPolicy?) - ForEach(AIConnectionPolicy.allCases) { policy in - Text(policy.displayName) - .tag(AIConnectionPolicy?.some(policy) as AIConnectionPolicy?) + if AppSettingsManager.shared.ai.enabled { + Section(String(localized: "AI")) { + Picker(String(localized: "AI Policy"), selection: $aiPolicy) { + Text(String(localized: "Use Default")) + .tag(AIConnectionPolicy?.none as AIConnectionPolicy?) + ForEach(AIConnectionPolicy.allCases) { policy in + Text(policy.displayName) + .tag(AIConnectionPolicy?.some(policy) as AIConnectionPolicy?) + } } } } diff --git a/TablePro/Views/Editor/AIEditorContextMenu.swift b/TablePro/Views/Editor/AIEditorContextMenu.swift index 399357a01..68a7a7ce3 100644 --- a/TablePro/Views/Editor/AIEditorContextMenu.swift +++ b/TablePro/Views/Editor/AIEditorContextMenu.swift @@ -60,7 +60,7 @@ final class AIEditorContextMenu: NSMenu, NSMenuDelegate { menu.addItem(saveAsFavItem) // AI items — only when text is selected - guard hasSelection?() == true else { return } + guard AppSettingsManager.shared.ai.enabled, hasSelection?() == true else { return } menu.addItem(.separator()) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift index fffd037d8..c656acdab 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift @@ -377,18 +377,26 @@ extension MainContentCoordinator { errorMessage: error.localizedDescription ) - // Show error alert with AI fix option + // Show error alert (with AI fix option when AI is enabled) let errorMessage = error.localizedDescription let queryCopy = sql Task { @MainActor in - let wantsAIFix = await AlertHelper.showQueryErrorWithAIOption( - title: String(localized: "Query Execution Failed"), - message: errorMessage, - window: NSApp.keyWindow - ) - if wantsAIFix { - showAIChatPanel() - aiViewModel?.handleFixError(query: queryCopy, error: errorMessage) + if AppSettingsManager.shared.ai.enabled { + let wantsAIFix = await AlertHelper.showQueryErrorWithAIOption( + title: String(localized: "Query Execution Failed"), + message: errorMessage, + window: NSApp.keyWindow + ) + if wantsAIFix { + showAIChatPanel() + aiViewModel?.handleFixError(query: queryCopy, error: errorMessage) + } + } else { + AlertHelper.showErrorSheet( + title: String(localized: "Query Execution Failed"), + message: errorMessage, + window: NSApp.keyWindow + ) } } } diff --git a/TablePro/Views/RightSidebar/UnifiedRightPanelView.swift b/TablePro/Views/RightSidebar/UnifiedRightPanelView.swift index 42e8f772b..58945b281 100644 --- a/TablePro/Views/RightSidebar/UnifiedRightPanelView.swift +++ b/TablePro/Views/RightSidebar/UnifiedRightPanelView.swift @@ -16,20 +16,40 @@ struct UnifiedRightPanelView: View { var body: some View { VStack(spacing: 0) { - // Tab switcher - Picker("", selection: $state.activeTab) { - ForEach(RightPanelTab.allCases, id: \.self) { tab in - Label(tab.localizedTitle, systemImage: tab.systemImage) - .tag(tab) + if AppSettingsManager.shared.ai.enabled { + // Tab switcher + Picker("", selection: $state.activeTab) { + ForEach(RightPanelTab.allCases, id: \.self) { tab in + Label(tab.localizedTitle, systemImage: tab.systemImage) + .tag(tab) + } } - } - .pickerStyle(.segmented) - .labelsHidden() - .padding(.horizontal, 12) - .padding(.vertical, 8) + .pickerStyle(.segmented) + .labelsHidden() + .padding(.horizontal, 12) + .padding(.vertical, 8) - switch state.activeTab { - case .details: + switch state.activeTab { + case .details: + RightSidebarView( + tableName: inspectorContext.tableName, + tableMetadata: inspectorContext.tableMetadata, + selectedRowData: inspectorContext.selectedRowData, + isEditable: inspectorContext.isEditable, + isRowDeleted: inspectorContext.isRowDeleted, + onSave: { state.onSave?() }, + editState: state.editState + ) + case .aiChat: + AIChatPanelView( + connection: connection, + tables: tables, + currentQuery: inspectorContext.currentQuery, + queryResults: inspectorContext.queryResults, + viewModel: state.aiViewModel + ) + } + } else { RightSidebarView( tableName: inspectorContext.tableName, tableMetadata: inspectorContext.tableMetadata, @@ -39,14 +59,6 @@ struct UnifiedRightPanelView: View { onSave: { state.onSave?() }, editState: state.editState ) - case .aiChat: - AIChatPanelView( - connection: connection, - tables: tables, - currentQuery: inspectorContext.currentQuery, - queryResults: inspectorContext.queryResults, - viewModel: state.aiViewModel - ) } } } diff --git a/TablePro/Views/Settings/AISettingsView.swift b/TablePro/Views/Settings/AISettingsView.swift index 6a965a9f0..90400d5a5 100644 --- a/TablePro/Views/Settings/AISettingsView.swift +++ b/TablePro/Views/Settings/AISettingsView.swift @@ -18,11 +18,16 @@ struct AISettingsView: View { var body: some View { Form { - providersSection - featureRoutingSection - contextSection - inlineSuggestionsSection - privacySection + Section { + Toggle(String(localized: "Enable AI Features"), isOn: $settings.enabled) + } + if settings.enabled { + providersSection + featureRoutingSection + contextSection + inlineSuggestionsSection + privacySection + } } .formStyle(.grouped) .sheet(item: $editingProvider) { provider in From f60668fc6835412aa5801e32be23fc1fbdf40b39 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 29 Mar 2026 14:59:30 +0700 Subject: [PATCH 2/2] fix: address review feedback for AI toggle --- .../RightSidebar/UnifiedRightPanelView.swift | 38 +++++++++---------- TableProTests/Models/AISettingsTests.swift | 32 ++++++++++++++++ 2 files changed, 51 insertions(+), 19 deletions(-) create mode 100644 TableProTests/Models/AISettingsTests.swift diff --git a/TablePro/Views/RightSidebar/UnifiedRightPanelView.swift b/TablePro/Views/RightSidebar/UnifiedRightPanelView.swift index 58945b281..8f239de79 100644 --- a/TablePro/Views/RightSidebar/UnifiedRightPanelView.swift +++ b/TablePro/Views/RightSidebar/UnifiedRightPanelView.swift @@ -14,10 +14,21 @@ struct UnifiedRightPanelView: View { let connection: DatabaseConnection let tables: [TableInfo] + private var detailsView: some View { + RightSidebarView( + tableName: inspectorContext.tableName, + tableMetadata: inspectorContext.tableMetadata, + selectedRowData: inspectorContext.selectedRowData, + isEditable: inspectorContext.isEditable, + isRowDeleted: inspectorContext.isRowDeleted, + onSave: { state.onSave?() }, + editState: state.editState + ) + } + var body: some View { VStack(spacing: 0) { if AppSettingsManager.shared.ai.enabled { - // Tab switcher Picker("", selection: $state.activeTab) { ForEach(RightPanelTab.allCases, id: \.self) { tab in Label(tab.localizedTitle, systemImage: tab.systemImage) @@ -31,15 +42,7 @@ struct UnifiedRightPanelView: View { switch state.activeTab { case .details: - RightSidebarView( - tableName: inspectorContext.tableName, - tableMetadata: inspectorContext.tableMetadata, - selectedRowData: inspectorContext.selectedRowData, - isEditable: inspectorContext.isEditable, - isRowDeleted: inspectorContext.isRowDeleted, - onSave: { state.onSave?() }, - editState: state.editState - ) + detailsView case .aiChat: AIChatPanelView( connection: connection, @@ -50,15 +53,12 @@ struct UnifiedRightPanelView: View { ) } } else { - RightSidebarView( - tableName: inspectorContext.tableName, - tableMetadata: inspectorContext.tableMetadata, - selectedRowData: inspectorContext.selectedRowData, - isEditable: inspectorContext.isEditable, - isRowDeleted: inspectorContext.isRowDeleted, - onSave: { state.onSave?() }, - editState: state.editState - ) + detailsView + } + } + .onChange(of: AppSettingsManager.shared.ai.enabled) { + if !AppSettingsManager.shared.ai.enabled { + state.activeTab = .details } } } diff --git a/TableProTests/Models/AISettingsTests.swift b/TableProTests/Models/AISettingsTests.swift new file mode 100644 index 000000000..7c57105eb --- /dev/null +++ b/TableProTests/Models/AISettingsTests.swift @@ -0,0 +1,32 @@ +// +// AISettingsTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("AISettings") +struct AISettingsTests { + @Test("default has enabled true") + func defaultEnabledIsTrue() { + #expect(AISettings.default.enabled == true) + } + + @Test("decoding without enabled key defaults to true") + func decodingWithoutEnabledDefaultsToTrue() throws { + let json = "{}" + let data = json.data(using: .utf8)! + let settings = try JSONDecoder().decode(AISettings.self, from: data) + #expect(settings.enabled == true) + } + + @Test("decoding with enabled false sets it correctly") + func decodingWithEnabledFalse() throws { + let json = "{\"enabled\": false}" + let data = json.data(using: .utf8)! + let settings = try JSONDecoder().decode(AISettings.self, from: data) + #expect(settings.enabled == false) + } +}