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 eb253e7ed..e164e87ef 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -11307,7 +11307,26 @@ } }, "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" : { 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 5d91ae0b0..c35dc61b2 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift @@ -379,18 +379,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..8f239de79 100644 --- a/TablePro/Views/RightSidebar/UnifiedRightPanelView.swift +++ b/TablePro/Views/RightSidebar/UnifiedRightPanelView.swift @@ -14,39 +14,51 @@ 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) { - // 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 { + 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: - 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 - ) + switch state.activeTab { + case .details: + detailsView + case .aiChat: + AIChatPanelView( + connection: connection, + tables: tables, + currentQuery: inspectorContext.currentQuery, + queryResults: inspectorContext.queryResults, + viewModel: state.aiViewModel + ) + } + } else { + detailsView + } + } + .onChange(of: AppSettingsManager.shared.ai.enabled) { + if !AppSettingsManager.shared.ai.enabled { + state.activeTab = .details } } } 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 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) + } +}