From cd9d6ff3d153f8d9fd5611c540789e4dd4dfcb02 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 13 Mar 2026 11:02:50 +0700 Subject: [PATCH 1/4] feat: replace hardcoded DatabaseType switches with dynamic plugin metadata lookups (#305) --- CHANGELOG.md | 5 + .../MongoDBDriverPlugin/MongoDBPlugin.swift | 2 + Plugins/OracleDriverPlugin/OraclePlugin.swift | 1 + .../PostgreSQLPlugin.swift | 3 + Plugins/RedisDriverPlugin/RedisPlugin.swift | 2 + Plugins/TableProPluginKit/DriverPlugin.swift | 6 + TablePro/AppDelegate+FileOpen.swift | 48 +++---- TablePro/ContentView.swift | 31 ++--- TablePro/Core/AI/AIPromptTemplates.swift | 19 ++- TablePro/Core/AI/AISchemaContext.swift | 38 +++--- .../Core/Autocomplete/SQLSchemaProvider.swift | 26 ++-- TablePro/Core/Plugins/PluginManager.swift | 117 ++++++++++++++++++ TablePro/Extensions/Color+Hex.swift | 22 ++++ .../EditorLanguage+TreeSitter.swift | 27 ++++ .../Connection/DatabaseConnection.swift | 2 +- TablePro/TableProApp.swift | 15 ++- TablePro/Theme/Theme.swift | 30 +---- TablePro/ViewModels/AIChatViewModel.swift | 14 +-- .../DatabaseSwitcherViewModel.swift | 24 +--- .../Views/Components/SQLReviewPopover.swift | 13 +- .../Views/Connection/ConnectionFormView.swift | 59 +++++---- TablePro/Views/Editor/SQLEditorView.swift | 2 +- TablePro/Views/Filter/FilterPanelView.swift | 2 +- .../Main/Child/MainEditorContentView.swift | 2 +- TablePro/Views/Main/MainContentView.swift | 8 +- .../Views/Sidebar/SidebarContextMenu.swift | 7 +- TablePro/Views/Sidebar/SidebarView.swift | 55 ++++---- .../Views/Sidebar/TableOperationDialog.swift | 29 ++--- .../Structure/TypePickerContentView.swift | 107 +++------------- .../Views/Toolbar/ConnectionStatusView.swift | 2 +- .../Toolbar/ConnectionSwitcherPopover.swift | 3 +- .../Views/Toolbar/TableProToolbarView.swift | 27 ++-- .../Models/DatabaseTypeRedisTests.swift | 6 +- .../Views/SidebarContextMenuLogicTests.swift | 13 +- 34 files changed, 409 insertions(+), 358 deletions(-) create mode 100644 TablePro/Extensions/Color+Hex.swift create mode 100644 TablePro/Extensions/EditorLanguage+TreeSitter.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 81f933aa..00f401d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Replaced ~40 hardcoded `DatabaseType` switches across ~20 UI files with dynamic plugin property lookups via `PluginManager`, so third-party plugins get correct UI behavior (colors, labels, editor language, feature toggles) automatically +- Replaced `AppState.isMongoDB`/`isRedis` booleans with `AppState.editorLanguage: EditorLanguage` for extensible editor language detection +- Theme colors now derived from plugin `brandColorHex` instead of hardcoded `Theme.mysqlColor` etc. +- Sidebar labels ("Tables"/"Collections"/"Keys"), toolbar preview labels, and AI prompt language detection now use plugin metadata +- Connection form, database switcher, type picker, file open handler, and toolbar all use plugin lookups for connection mode, authentication, import support, and system database names - Converted `DatabaseType` from closed enum to string-based struct, enabling future plugin-defined database types - Moved string literal escaping into plugin drivers via `escapeStringLiteral` on `PluginDatabaseDriver` and `DatabaseDriver` protocols; `SQLEscaping.escapeStringLiteral` now uses ANSI SQL escaping only (doubles single quotes, strips null bytes) - SQL autocomplete data types and CREATE TABLE options now use plugin-provided dialect data instead of hardcoded per-database switches diff --git a/Plugins/MongoDBDriverPlugin/MongoDBPlugin.swift b/Plugins/MongoDBDriverPlugin/MongoDBPlugin.swift index 1dcee803..c3a8f18d 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBPlugin.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBPlugin.swift @@ -53,6 +53,8 @@ final class MongoDBPlugin: NSObject, TableProPlugin, DriverPlugin { static let supportsForeignKeys = false static let supportsSchemaEditing = false static let systemDatabaseNames: [String] = ["admin", "local", "config"] + static let tableEntityName = "Collections" + static let supportsForeignKeyDisable = false static let databaseGroupingStrategy: GroupingStrategy = .flat static let columnTypesByCategory: [String: [String]] = [ "String": ["string", "objectId", "regex"], diff --git a/Plugins/OracleDriverPlugin/OraclePlugin.swift b/Plugins/OracleDriverPlugin/OraclePlugin.swift index 51f3775c..fb07bd1a 100644 --- a/Plugins/OracleDriverPlugin/OraclePlugin.swift +++ b/Plugins/OracleDriverPlugin/OraclePlugin.swift @@ -23,6 +23,7 @@ final class OraclePlugin: NSObject, TableProPlugin, DriverPlugin { // MARK: - UI/Capability Metadata + static let supportsForeignKeyDisable = false static let brandColorHex = "#C3160B" static let systemDatabaseNames: [String] = ["SYS", "SYSTEM", "OUTLN", "DBSNMP", "APPQOSSYS", "WMSYS", "XDB"] static let databaseGroupingStrategy: GroupingStrategy = .bySchema diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift index f37856db..ef25b9b3 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift @@ -47,6 +47,9 @@ final class PostgreSQLPlugin: NSObject, TableProPlugin, DriverPlugin { "XML": ["XML"] ] + static let supportsCascadeDrop = true + static let supportsForeignKeyDisable = false + static let sqlDialect: SQLDialectDescriptor? = SQLDialectDescriptor( identifierQuote: "\"", keywords: [ diff --git a/Plugins/RedisDriverPlugin/RedisPlugin.swift b/Plugins/RedisDriverPlugin/RedisPlugin.swift index 0a9e776a..c6230c22 100644 --- a/Plugins/RedisDriverPlugin/RedisPlugin.swift +++ b/Plugins/RedisDriverPlugin/RedisPlugin.swift @@ -42,6 +42,8 @@ final class RedisPlugin: NSObject, TableProPlugin, DriverPlugin { static let supportsSchemaEditing = false static let supportsDatabaseSwitching = false static let supportsImport = false + static let tableEntityName = "Keys" + static let supportsForeignKeyDisable = false static let databaseGroupingStrategy: GroupingStrategy = .flat static let defaultGroupName = "db0" static let columnTypesByCategory: [String: [String]] = [ diff --git a/Plugins/TableProPluginKit/DriverPlugin.swift b/Plugins/TableProPluginKit/DriverPlugin.swift index d6af06d9..df9659f7 100644 --- a/Plugins/TableProPluginKit/DriverPlugin.swift +++ b/Plugins/TableProPluginKit/DriverPlugin.swift @@ -36,6 +36,9 @@ public protocol DriverPlugin: TableProPlugin { static var columnTypesByCategory: [String: [String]] { get } static var sqlDialect: SQLDialectDescriptor? { get } static var statementCompletions: [CompletionEntry] { get } + static var tableEntityName: String { get } + static var supportsCascadeDrop: Bool { get } + static var supportsForeignKeyDisable: Bool { get } } public extension DriverPlugin { @@ -76,4 +79,7 @@ public extension DriverPlugin { } static var sqlDialect: SQLDialectDescriptor? { nil } static var statementCompletions: [CompletionEntry] { [] } + static var tableEntityName: String { "Tables" } + static var supportsCascadeDrop: Bool { false } + static var supportsForeignKeyDisable: Bool { true } } diff --git a/TablePro/AppDelegate+FileOpen.swift b/TablePro/AppDelegate+FileOpen.swift index 109ca0ab..af0be3d2 100644 --- a/TablePro/AppDelegate+FileOpen.swift +++ b/TablePro/AppDelegate+FileOpen.swift @@ -14,32 +14,21 @@ private let fileOpenLogger = Logger(subsystem: "com.TablePro", category: "FileOp extension AppDelegate { // MARK: - URL Classification - private static let databaseURLSchemes: Set = [ - "postgresql", "postgres", "mysql", "mariadb", "sqlite", - "mongodb", "mongodb+srv", "redis", "rediss", "redshift", - "mssql", "sqlserver", "oracle", "duckdb" - ] - - static let sqliteFileExtensions: Set = [ - "sqlite", "sqlite3", "db3", "s3db", "sl3", "sqlitedb" - ] - - static let duckdbFileExtensions: Set = ["duckdb", "ddb"] - private func isDatabaseURL(_ url: URL) -> Bool { guard let scheme = url.scheme?.lowercased() else { return false } let base = scheme .replacingOccurrences(of: "+ssh", with: "") .replacingOccurrences(of: "+srv", with: "") - return Self.databaseURLSchemes.contains(base) || Self.databaseURLSchemes.contains(scheme) + let registeredSchemes = PluginManager.shared.allRegisteredURLSchemes + return registeredSchemes.contains(base) || registeredSchemes.contains(scheme) } - private func isSQLiteFile(_ url: URL) -> Bool { - Self.sqliteFileExtensions.contains(url.pathExtension.lowercased()) + private func isDatabaseFile(_ url: URL) -> Bool { + PluginManager.shared.allRegisteredFileExtensions[url.pathExtension.lowercased()] != nil } - private func isDuckDBFile(_ url: URL) -> Bool { - Self.duckdbFileExtensions.contains(url.pathExtension.lowercased()) + private func databaseTypeForFile(_ url: URL) -> DatabaseType? { + PluginManager.shared.allRegisteredFileExtensions[url.pathExtension.lowercased()] } // MARK: - Main Dispatch @@ -68,20 +57,21 @@ extension AppDelegate { } } - let sqliteFiles = urls.filter { isSQLiteFile($0) } - if !sqliteFiles.isEmpty { - suppressWelcomeWindow() - Task { @MainActor in - for url in sqliteFiles { self.handleSQLiteFile(url) } - self.scheduleWelcomeWindowSuppression() - } - } - - let duckdbFiles = urls.filter { isDuckDBFile($0) } - if !duckdbFiles.isEmpty { + let databaseFiles = urls.filter { isDatabaseFile($0) } + if !databaseFiles.isEmpty { suppressWelcomeWindow() Task { @MainActor in - for url in duckdbFiles { self.handleDuckDBFile(url) } + for url in databaseFiles { + guard let dbType = self.databaseTypeForFile(url) else { continue } + switch dbType { + case .sqlite: + self.handleSQLiteFile(url) + case .duckdb: + self.handleDuckDBFile(url) + default: + break + } + } self.scheduleWelcomeWindowSuppression() } } diff --git a/TablePro/ContentView.swift b/TablePro/ContentView.swift index 7d9a2e4c..4e0c4f93 100644 --- a/TablePro/ContentView.swift +++ b/TablePro/ContentView.swift @@ -8,6 +8,7 @@ import AppKit import os import SwiftUI +import TableProPluginKit struct ContentView: View { private static let logger = Logger(subsystem: "com.TablePro", category: "ContentView") @@ -40,11 +41,8 @@ struct ContentView: View { defaultTitle = tableName } else if let connectionId = payload?.connectionId, let connection = ConnectionStorage.shared.loadConnections().first(where: { $0.id == connectionId }) { - switch connection.type { - case .mongodb: defaultTitle = "MQL Query" - case .redis: defaultTitle = "Redis CLI" - default: defaultTitle = "SQL Query" - } + let langName = PluginManager.shared.queryLanguageName(for: connection.type) + defaultTitle = langName == "SQL" ? "SQL Query" : langName == "MQL" ? "MQL Query" : langName } else { defaultTitle = "SQL Query" } @@ -94,8 +92,9 @@ struct ContentView: View { } AppState.shared.isConnected = true AppState.shared.safeModeLevel = session.connection.safeModeLevel - AppState.shared.isMongoDB = session.connection.type == .mongodb - AppState.shared.isRedis = session.connection.type == .redis + AppState.shared.editorLanguage = PluginManager.shared.editorLanguage(for: session.connection.type) + AppState.shared.supportsDatabaseSwitching = PluginManager.shared.supportsDatabaseSwitching( + for: session.connection.type) } } else { currentSession = nil @@ -119,8 +118,8 @@ struct ContentView: View { columnVisibility = .detailOnly AppState.shared.isConnected = false AppState.shared.safeModeLevel = .silent - AppState.shared.isMongoDB = false - AppState.shared.isRedis = false + AppState.shared.editorLanguage = .sql + AppState.shared.supportsDatabaseSwitching = true // Close all native tab windows for this connection and // force AppKit to deallocate them instead of pooling. @@ -150,8 +149,9 @@ struct ContentView: View { } AppState.shared.isConnected = true AppState.shared.safeModeLevel = newSession.connection.safeModeLevel - AppState.shared.isMongoDB = newSession.connection.type == .mongodb - AppState.shared.isRedis = newSession.connection.type == .redis + AppState.shared.editorLanguage = PluginManager.shared.editorLanguage(for: newSession.connection.type) + AppState.shared.supportsDatabaseSwitching = PluginManager.shared.supportsDatabaseSwitching( + for: newSession.connection.type) } .onReceive(NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification)) { notification in // Only process notifications for our own window to avoid every @@ -178,13 +178,14 @@ struct ContentView: View { if let session = DatabaseManager.shared.activeSessions[connectionId] { AppState.shared.isConnected = true AppState.shared.safeModeLevel = session.connection.safeModeLevel - AppState.shared.isMongoDB = session.connection.type == .mongodb - AppState.shared.isRedis = session.connection.type == .redis + AppState.shared.editorLanguage = PluginManager.shared.editorLanguage(for: session.connection.type) + AppState.shared.supportsDatabaseSwitching = PluginManager.shared.supportsDatabaseSwitching( + for: session.connection.type) } else { AppState.shared.isConnected = false AppState.shared.safeModeLevel = .silent - AppState.shared.isMongoDB = false - AppState.shared.isRedis = false + AppState.shared.editorLanguage = .sql + AppState.shared.supportsDatabaseSwitching = true } } } diff --git a/TablePro/Core/AI/AIPromptTemplates.swift b/TablePro/Core/AI/AIPromptTemplates.swift index 97680298..c6338989 100644 --- a/TablePro/Core/AI/AIPromptTemplates.swift +++ b/TablePro/Core/AI/AIPromptTemplates.swift @@ -10,31 +10,26 @@ import Foundation /// Centralized prompt templates for AI-powered editor features enum AIPromptTemplates { /// Build a prompt asking AI to explain a query - static func explainQuery(_ query: String, databaseType: DatabaseType = .mysql) -> String { + @MainActor static func explainQuery(_ query: String, databaseType: DatabaseType = .mysql) -> String { let (typeName, lang) = queryInfo(for: databaseType) return "Explain this \(typeName):\n\n```\(lang)\n\(query)\n```" } /// Build a prompt asking AI to optimize a query - static func optimizeQuery(_ query: String, databaseType: DatabaseType = .mysql) -> String { + @MainActor static func optimizeQuery(_ query: String, databaseType: DatabaseType = .mysql) -> String { let (typeName, lang) = queryInfo(for: databaseType) return "Optimize this \(typeName) for better performance:\n\n```\(lang)\n\(query)\n```" } /// Build a prompt asking AI to fix a query that produced an error - static func fixError(query: String, error: String, databaseType: DatabaseType = .mysql) -> String { + @MainActor static func fixError(query: String, error: String, databaseType: DatabaseType = .mysql) -> String { let (typeName, lang) = queryInfo(for: databaseType) return "This \(typeName) failed with an error. Please fix it.\n\nQuery:\n```\(lang)\n\(query)\n```\n\nError: \(error)" } - private static func queryInfo(for databaseType: DatabaseType) -> (typeName: String, language: String) { - switch databaseType { - case .mongodb: - return ("MongoDB query", "javascript") - case .redis: - return ("Redis command", "bash") - default: - return ("SQL query", "sql") - } + @MainActor private static func queryInfo(for databaseType: DatabaseType) -> (typeName: String, language: String) { + let langName = PluginManager.shared.queryLanguageName(for: databaseType) + let lang = PluginManager.shared.editorLanguage(for: databaseType).codeBlockTag + return ("\(langName) query", lang) } } diff --git a/TablePro/Core/AI/AISchemaContext.swift b/TablePro/Core/AI/AISchemaContext.swift index 458ad384..15acb2c2 100644 --- a/TablePro/Core/AI/AISchemaContext.swift +++ b/TablePro/Core/AI/AISchemaContext.swift @@ -7,6 +7,7 @@ import Foundation import os +import TableProPluginKit /// Builds schema context for AI system prompts struct AISchemaContext { @@ -18,7 +19,7 @@ struct AISchemaContext { // MARK: - Public /// Build a system prompt including database context - static func buildSystemPrompt( + @MainActor static func buildSystemPrompt( databaseType: DatabaseType, databaseName: String, tables: [TableInfo], @@ -55,12 +56,7 @@ struct AISchemaContext { if settings.includeCurrentQuery, let query = currentQuery, !query.isEmpty { - let lang: String - switch databaseType { - case .mongodb: lang = "javascript" - case .redis: lang = "bash" - default: lang = "sql" - } + let lang = PluginManager.shared.editorLanguage(for: databaseType).codeBlockTag parts.append("\n## Current Query\n```\(lang)\n\(query)\n```") } @@ -70,21 +66,12 @@ struct AISchemaContext { parts.append("\n## Recent Query Results\n\(results)") } - if databaseType == .mongodb { - parts.append( - "\nProvide MongoDB shell queries using `javascript` fenced code blocks." - ) - parts.append( - "Use MongoDB shell syntax (db.collection.find(), etc.), not SQL." - ) - } else if databaseType == .redis { - parts.append( - "\nProvide Redis commands using `bash` fenced code blocks." - ) - parts.append( - "Use Redis CLI syntax (GET, SET, HGETALL, etc.), not SQL." - ) - } else { + let editorLang = PluginManager.shared.editorLanguage(for: databaseType) + let langName = PluginManager.shared.queryLanguageName(for: databaseType) + let langTag = editorLang.codeBlockTag + + switch editorLang { + case .sql: parts.append( "\nProvide SQL queries appropriate for" + " \(databaseType.rawValue) syntax when applicable." @@ -93,6 +80,13 @@ struct AISchemaContext { "When writing SQL, use the correct identifier quoting" + " for \(databaseType.rawValue)." ) + default: + parts.append( + "\nProvide \(langName) queries using `\(langTag)` fenced code blocks." + ) + parts.append( + "Use \(langName) syntax, not SQL." + ) } return parts.joined(separator: "\n") diff --git a/TablePro/Core/Autocomplete/SQLSchemaProvider.swift b/TablePro/Core/Autocomplete/SQLSchemaProvider.swift index b15ca164..30e922e2 100644 --- a/TablePro/Core/Autocomplete/SQLSchemaProvider.swift +++ b/TablePro/Core/Autocomplete/SQLSchemaProvider.swift @@ -141,21 +141,25 @@ actor SQLSchemaProvider { } let dbType = connection.type + let dbName = connection.database + let capturedTables = tables let idQuote = await MainActor.run { PluginManager.shared.sqlDialect(for: dbType)?.identifierQuote ?? "\"" } - return AISchemaContext.buildSystemPrompt( - databaseType: connection.type, - databaseName: connection.database, - tables: tables, - columnsByTable: columnsByTable, - foreignKeys: [:], - currentQuery: nil, - queryResults: nil, - settings: settings, - identifierQuote: idQuote - ) + return await MainActor.run { + AISchemaContext.buildSystemPrompt( + databaseType: dbType, + databaseName: dbName, + tables: capturedTables, + columnsByTable: columnsByTable, + foreignKeys: [:], + currentQuery: nil, + queryResults: nil, + settings: settings, + identifierQuote: idQuote + ) + } } // MARK: - Completion Items diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index 6081532b..93eac522 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -6,12 +6,22 @@ import Foundation import os import Security +import SwiftUI import TableProPluginKit @MainActor @Observable final class PluginManager { static let shared = PluginManager() static let currentPluginKitVersion = 1 + static let defaultColumnTypes: [String: [String]] = [ + "Integer": ["INTEGER", "INT", "SMALLINT", "BIGINT", "TINYINT"], + "Float": ["FLOAT", "DOUBLE", "DECIMAL", "NUMERIC", "REAL"], + "String": ["VARCHAR", "CHAR", "TEXT", "NVARCHAR", "NCHAR"], + "Date": ["DATE", "TIME", "DATETIME", "TIMESTAMP"], + "Binary": ["BLOB", "BINARY", "VARBINARY"], + "Boolean": ["BOOLEAN", "BOOL"], + "JSON": ["JSON"] + ] private static let disabledPluginsKey = "com.TablePro.disabledPlugins" private static let legacyDisabledPluginsKey = "disabledPlugins" @@ -365,6 +375,113 @@ final class PluginManager { return Swift.type(of: plugin).additionalConnectionFields } + // MARK: - Plugin Property Lookups + + func driverPlugin(for databaseType: DatabaseType) -> (any DriverPlugin)? { + loadPendingPlugins() + return driverPlugins[databaseType.pluginTypeId] + } + + func editorLanguage(for databaseType: DatabaseType) -> EditorLanguage { + guard let plugin = driverPlugin(for: databaseType) else { return .sql } + return Swift.type(of: plugin).editorLanguage + } + + func queryLanguageName(for databaseType: DatabaseType) -> String { + guard let plugin = driverPlugin(for: databaseType) else { return "SQL" } + return Swift.type(of: plugin).queryLanguageName + } + + func connectionMode(for databaseType: DatabaseType) -> ConnectionMode { + guard let plugin = driverPlugin(for: databaseType) else { return .network } + return Swift.type(of: plugin).connectionMode + } + + func brandColor(for databaseType: DatabaseType) -> Color { + guard let plugin = driverPlugin(for: databaseType) else { return Theme.defaultDatabaseColor } + return Color(hex: Swift.type(of: plugin).brandColorHex) + } + + func supportsDatabaseSwitching(for databaseType: DatabaseType) -> Bool { + guard let plugin = driverPlugin(for: databaseType) else { return true } + return Swift.type(of: plugin).supportsDatabaseSwitching + } + + func supportsSchemaSwitching(for databaseType: DatabaseType) -> Bool { + guard let plugin = driverPlugin(for: databaseType) else { return false } + return Swift.type(of: plugin).supportsSchemaSwitching + } + + func supportsImport(for databaseType: DatabaseType) -> Bool { + guard let plugin = driverPlugin(for: databaseType) else { return true } + return Swift.type(of: plugin).supportsImport + } + + func systemDatabaseNames(for databaseType: DatabaseType) -> [String] { + guard let plugin = driverPlugin(for: databaseType) else { return [] } + return Swift.type(of: plugin).systemDatabaseNames + } + + func systemSchemaNames(for databaseType: DatabaseType) -> [String] { + guard let plugin = driverPlugin(for: databaseType) else { return [] } + return Swift.type(of: plugin).systemSchemaNames + } + + func columnTypesByCategory(for databaseType: DatabaseType) -> [String: [String]] { + guard let plugin = driverPlugin(for: databaseType) else { return Self.defaultColumnTypes } + return Swift.type(of: plugin).columnTypesByCategory + } + + func requiresAuthentication(for databaseType: DatabaseType) -> Bool { + guard let plugin = driverPlugin(for: databaseType) else { return true } + return Swift.type(of: plugin).requiresAuthentication + } + + func fileExtensions(for databaseType: DatabaseType) -> [String] { + guard let plugin = driverPlugin(for: databaseType) else { return [] } + return Swift.type(of: plugin).fileExtensions + } + + func tableEntityName(for databaseType: DatabaseType) -> String { + guard let plugin = driverPlugin(for: databaseType) else { return "Tables" } + return Swift.type(of: plugin).tableEntityName + } + + func supportsCascadeDrop(for databaseType: DatabaseType) -> Bool { + guard let plugin = driverPlugin(for: databaseType) else { return false } + return Swift.type(of: plugin).supportsCascadeDrop + } + + func supportsForeignKeyDisable(for databaseType: DatabaseType) -> Bool { + guard let plugin = driverPlugin(for: databaseType) else { return true } + return Swift.type(of: plugin).supportsForeignKeyDisable + } + + /// All file extensions across all loaded plugins. + var allRegisteredFileExtensions: [String: DatabaseType] { + loadPendingPlugins() + var result: [String: DatabaseType] = [:] + for (typeId, plugin) in driverPlugins { + let dbType = DatabaseType(rawValue: typeId) + for ext in Swift.type(of: plugin).fileExtensions { + result[ext.lowercased()] = dbType + } + } + return result + } + + /// All URL schemes across all loaded plugins. + var allRegisteredURLSchemes: Set { + loadPendingPlugins() + var result: Set = [] + for plugin in driverPlugins.values { + for scheme in Swift.type(of: plugin).urlSchemes { + result.insert(scheme.lowercased()) + } + } + return result + } + func installMissingPlugin( for databaseType: DatabaseType, progress: @escaping @MainActor @Sendable (Double) -> Void diff --git a/TablePro/Extensions/Color+Hex.swift b/TablePro/Extensions/Color+Hex.swift new file mode 100644 index 00000000..ba99673f --- /dev/null +++ b/TablePro/Extensions/Color+Hex.swift @@ -0,0 +1,22 @@ +// +// Color+Hex.swift +// TablePro +// + +import SwiftUI + +extension Color { + /// Creates a Color from a hex string like "#FF8800" or "FF8800". + init(hex: String) { + let hex = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#")) + let scanner = Scanner(string: hex) + var rgbValue: UInt64 = 0 + scanner.scanHexInt64(&rgbValue) + + let red = Double((rgbValue >> 16) & 0xFF) / 255.0 + let green = Double((rgbValue >> 8) & 0xFF) / 255.0 + let blue = Double(rgbValue & 0xFF) / 255.0 + + self.init(red: red, green: green, blue: blue) + } +} diff --git a/TablePro/Extensions/EditorLanguage+TreeSitter.swift b/TablePro/Extensions/EditorLanguage+TreeSitter.swift new file mode 100644 index 00000000..164e39d5 --- /dev/null +++ b/TablePro/Extensions/EditorLanguage+TreeSitter.swift @@ -0,0 +1,27 @@ +// +// EditorLanguage+TreeSitter.swift +// TablePro +// + +import CodeEditLanguages +import TableProPluginKit + +extension EditorLanguage { + var treeSitterLanguage: CodeLanguage { + switch self { + case .sql: return .sql + case .javascript: return .javascript + case .bash: return .bash + case .custom: return .sql + } + } + + var codeBlockTag: String { + switch self { + case .sql: return "sql" + case .javascript: return "javascript" + case .bash: return "bash" + case .custom(let name): return name + } + } +} diff --git a/TablePro/Models/Connection/DatabaseConnection.swift b/TablePro/Models/Connection/DatabaseConnection.swift index faaaa8fc..d62c94fb 100644 --- a/TablePro/Models/Connection/DatabaseConnection.swift +++ b/TablePro/Models/Connection/DatabaseConnection.swift @@ -495,7 +495,7 @@ struct DatabaseConnection: Identifiable, Hashable { } /// Returns the display color (custom color or database type color) - var displayColor: Color { + @MainActor var displayColor: Color { color.isDefault ? type.themeColor : color.color } } diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index 5ce90ad5..9940aefc 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -9,6 +9,7 @@ import CodeEditTextView import Observation import Sparkle import SwiftUI +import TableProPluginKit // MARK: - App State for Menu Commands @@ -19,8 +20,8 @@ final class AppState { var isConnected: Bool = false var safeModeLevel: SafeModeLevel = .silent var isReadOnly: Bool { safeModeLevel.blocksAllWrites } - var isMongoDB: Bool = false - var isRedis: Bool = false + var editorLanguage: EditorLanguage = .sql + var supportsDatabaseSwitching: Bool = true var isCurrentTabEditable: Bool = false // True when current tab is an editable table var hasRowSelection: Bool = false // True when rows are selected in data grid var hasTableSelection: Bool = false // True when tables are selected in sidebar @@ -171,13 +172,13 @@ struct AppMenuCommands: Commands { actions?.openDatabaseSwitcher() } .optionalKeyboardShortcut(shortcut(for: .openDatabase)) - .disabled(!appState.isConnected || appState.isRedis) + .disabled(!appState.isConnected || !appState.supportsDatabaseSwitching) Button("Switch Connection...") { NotificationCenter.default.post(name: .openConnectionSwitcher, object: nil) } .optionalKeyboardShortcut(shortcut(for: .switchConnection)) - .disabled(!appState.isConnected || appState.isRedis) + .disabled(!appState.isConnected || !appState.supportsDatabaseSwitching) Button("Quick Switcher...") { actions?.openQuickSwitcher() @@ -193,7 +194,9 @@ struct AppMenuCommands: Commands { .optionalKeyboardShortcut(shortcut(for: .saveChanges)) .disabled(!appState.isConnected || appState.isReadOnly) - Button(appState.isMongoDB ? "Preview MQL" : appState.isRedis ? "Preview Commands" : "Preview SQL") { + Button(appState.editorLanguage == .javascript ? "Preview MQL" + : appState.editorLanguage == .bash ? "Preview Commands" + : "Preview SQL") { actions?.previewSQL() } .optionalKeyboardShortcut(shortcut(for: .previewSQL)) @@ -230,7 +233,7 @@ struct AppMenuCommands: Commands { .optionalKeyboardShortcut(shortcut(for: .export)) .disabled(!appState.isConnected) - if !appState.isMongoDB && !appState.isRedis { + if appState.editorLanguage == .sql { Button("Import...") { actions?.importTables() } diff --git a/TablePro/Theme/Theme.swift b/TablePro/Theme/Theme.swift index 91a39631..1904b8a7 100644 --- a/TablePro/Theme/Theme.swift +++ b/TablePro/Theme/Theme.swift @@ -13,18 +13,6 @@ enum Theme { // MARK: - Brand Colors static let primaryColor = Color("AccentColor") - - static let mysqlColor = Color(nsColor: .systemOrange) - static let postgresqlColor = Color(nsColor: .systemBlue) - static let sqliteColor = Color(nsColor: .systemGreen) - static let mariadbColor = Color(nsColor: .systemCyan) - static let mongodbColor = Color(red: 0.0, green: 0.93, blue: 0.39) - static let redshiftColor = Color(red: 0.13, green: 0.36, blue: 0.59) - static let redisColor = Color(red: 0.86, green: 0.22, blue: 0.18) // #DC382D - static let mssqlColor = Color(red: 0.89, green: 0.27, blue: 0.09) - static let oracleColor = Color(red: 0.76, green: 0.09, blue: 0.07) // #C3160B Oracle red - static let clickhouseColor = Color(red: 1.0, green: 0.82, blue: 0.0) - static let duckdbColor = Color(red: 1.0, green: 0.85, blue: 0.0) static let defaultDatabaseColor = Color.gray // MARK: - Semantic Colors @@ -100,21 +88,7 @@ extension View { // MARK: - Database Type Colors extension DatabaseType { - var themeColor: Color { - Self.themeColorMap[self] ?? Theme.defaultDatabaseColor + @MainActor var themeColor: Color { + PluginManager.shared.brandColor(for: self) } - - private static let themeColorMap: [DatabaseType: Color] = [ - .mysql: Theme.mysqlColor, - .mariadb: Theme.mariadbColor, - .postgresql: Theme.postgresqlColor, - .sqlite: Theme.sqliteColor, - .redshift: Theme.redshiftColor, - .mongodb: Theme.mongodbColor, - .redis: Theme.redisColor, - .mssql: Theme.mssqlColor, - .oracle: Theme.oracleColor, - .clickhouse: Theme.clickhouseColor, - .duckdb: Theme.duckdbColor, - ] } diff --git a/TablePro/ViewModels/AIChatViewModel.swift b/TablePro/ViewModels/AIChatViewModel.swift index 9a294bba..feabecee 100644 --- a/TablePro/ViewModels/AIChatViewModel.swift +++ b/TablePro/ViewModels/AIChatViewModel.swift @@ -52,19 +52,13 @@ final class AIChatViewModel { // MARK: - AI Action Dispatch private var queryLanguage: String { - switch connection?.type { - case .mongodb: return "javascript" - case .redis: return "bash" - default: return "sql" - } + guard let type = connection?.type else { return "sql" } + return PluginManager.shared.editorLanguage(for: type).codeBlockTag } private var queryTypeName: String { - switch connection?.type { - case .mongodb: return "MongoDB query" - case .redis: return "Redis command" - default: return "SQL query" - } + guard let type = connection?.type else { return "SQL query" } + return "\(PluginManager.shared.queryLanguageName(for: type)) query" } func handleFixError(query: String, error: String) { diff --git a/TablePro/ViewModels/DatabaseSwitcherViewModel.swift b/TablePro/ViewModels/DatabaseSwitcherViewModel.swift index 7be13baf..3d87267d 100644 --- a/TablePro/ViewModels/DatabaseSwitcherViewModel.swift +++ b/TablePro/ViewModels/DatabaseSwitcherViewModel.swift @@ -77,7 +77,7 @@ final class DatabaseSwitcherViewModel { self.currentDatabase = currentDatabase self.currentSchema = currentSchema self.databaseType = databaseType - self.mode = databaseType == .redshift ? .schema : .database + self.mode = PluginManager.shared.supportsSchemaSwitching(for: databaseType) ? .schema : .database self.recentDatabases = UserDefaults.standard.recentDatabases(for: connectionId) } @@ -168,26 +168,12 @@ final class DatabaseSwitcherViewModel { } } - /// Determine if a database or schema is a system item private func isSystemItem(_ name: String) -> Bool { if isSchemaMode { - return name.hasPrefix("pg_") + let schemaNames = PluginManager.shared.systemSchemaNames(for: databaseType) + return schemaNames.contains(name) } - if databaseType == .duckdb { - return Self.duckdbSystemItems.contains(name.lowercased()) - } - return Self.systemItemNames[databaseType]?.contains(name) ?? false + let dbNames = PluginManager.shared.systemDatabaseNames(for: databaseType) + return dbNames.contains(name) } - - private static let duckdbSystemItems: Set = ["information_schema", "pg_catalog"] - - private static let systemItemNames: [DatabaseType: Set] = [ - .mysql: ["information_schema", "mysql", "performance_schema", "sys"], - .mariadb: ["information_schema", "mysql", "performance_schema", "sys"], - .postgresql: ["postgres", "template0", "template1"], - .redshift: ["dev", "padb_harvest"], - .clickhouse: ["information_schema", "INFORMATION_SCHEMA", "system"], - .mssql: ["master", "tempdb", "model", "msdb"], - .oracle: ["SYS", "SYSTEM", "OUTLN", "DBSNMP", "APPQOSSYS", "WMSYS", "XDB"], - ] } diff --git a/TablePro/Views/Components/SQLReviewPopover.swift b/TablePro/Views/Components/SQLReviewPopover.swift index b22f389b..0eeafcc6 100644 --- a/TablePro/Views/Components/SQLReviewPopover.swift +++ b/TablePro/Views/Components/SQLReviewPopover.swift @@ -9,6 +9,7 @@ import AppKit import CodeEditLanguages import CodeEditSourceEditor import SwiftUI +import TableProPluginKit /// Popover view that displays SQL statements with tree-sitter syntax highlighting for review before commit. struct SQLReviewPopover: View { @@ -23,7 +24,7 @@ struct SQLReviewPopover: View { /// All statements joined for display private var combinedSQL: String { let joined = statements.map { $0.hasSuffix(";") ? $0 : $0 + ";" }.joined(separator: "\n\n") - if databaseType == .mongodb { + if PluginManager.shared.editorLanguage(for: databaseType) == .javascript { return Self.convertExtendedJsonToShellSyntax(joined) } return joined @@ -99,11 +100,7 @@ struct SQLReviewPopover: View { private var headerView: some View { HStack { - Text(databaseType == .mongodb - ? String(localized: "MQL Preview") - : databaseType == .redis - ? String(localized: "Command Preview") - : String(localized: "SQL Preview")) + Text(String(localized: "\(PluginManager.shared.queryLanguageName(for: databaseType)) Preview")) .font(.system(size: DesignConstants.FontSize.body, weight: .semibold)) if !statements.isEmpty { Text( @@ -149,7 +146,7 @@ struct SQLReviewPopover: View { if isEditorReady { SourceEditor( .constant(combinedSQL), - language: databaseType == .mongodb ? .javascript : databaseType == .redis ? .bash : .sql, + language: PluginManager.shared.editorLanguage(for: databaseType).treeSitterLanguage, configuration: Self.makeConfiguration(), state: $editorState ) @@ -197,7 +194,7 @@ struct SQLReviewPopover: View { private func copyAllToClipboard() { var joined = statements.map { $0.hasSuffix(";") ? $0 : $0 + ";" }.joined(separator: "\n\n") - if databaseType == .mongodb { + if PluginManager.shared.editorLanguage(for: databaseType) == .javascript { joined = Self.convertExtendedJsonToShellSyntax(joined) } ClipboardService.shared.writeText(joined) diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index af246d6a..4fc56933 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -25,7 +25,7 @@ struct ConnectionFormView: View { private var isNew: Bool { connectionId == nil } private var availableDatabaseTypes: [DatabaseType] { - DatabaseType.allKnownTypes + PluginManager.shared.availableDatabaseTypes } private var additionalConnectionFields: [ConnectionField] { @@ -143,11 +143,12 @@ struct ConnectionFormView: View { if hasLoadedData { port = String(newType.defaultPort) } - if (newType == .sqlite || newType == .duckdb) && (selectedTab == .ssh || selectedTab == .ssl) { + let isFileBased = PluginManager.shared.connectionMode(for: newType) == .fileBased + if isFileBased && (selectedTab == .ssh || selectedTab == .ssl) { selectedTab = .general } additionalFieldValues = [:] - if newType != .postgresql && newType != .redshift { + if newType.pluginTypeId != "PostgreSQL" { usePgpass = false } for field in PluginManager.shared.additionalConnectionFields(for: newType) { @@ -169,7 +170,7 @@ struct ConnectionFormView: View { // MARK: - Tab Picker Helpers private var visibleTabs: [FormTab] { - if type == .sqlite || type == .duckdb { + if PluginManager.shared.connectionMode(for: type) == .fileBased { return [.general, .advanced] } return FormTab.allCases @@ -232,13 +233,13 @@ struct ConnectionFormView: View { } } - if type == .sqlite || type == .duckdb { + if PluginManager.shared.connectionMode(for: type) == .fileBased { Section(String(localized: "Database File")) { HStack { TextField( String(localized: "File Path"), text: $database, - prompt: Text(type == .duckdb ? "/path/to/database.duckdb" : "/path/to/database.sqlite") + prompt: Text(filePathPrompt) ) Button(String(localized: "Browse...")) { browseForFile() } .controlSize(.small) @@ -256,7 +257,7 @@ struct ConnectionFormView: View { text: $port, prompt: Text(defaultPort) ) - if type != .redis { + if PluginManager.shared.requiresAuthentication(for: type) { TextField( String(localized: "Database"), text: $database, @@ -265,23 +266,23 @@ struct ConnectionFormView: View { } } Section(String(localized: "Authentication")) { - if type != .redis { + if PluginManager.shared.requiresAuthentication(for: type) { TextField( String(localized: "Username"), text: $username, prompt: Text("root") ) } - if type == .postgresql || type == .redshift { + if type.pluginTypeId == "PostgreSQL" { Toggle(String(localized: "Use ~/.pgpass"), isOn: $usePgpass) } - if !usePgpass || (type != .postgresql && type != .redshift) { + if !usePgpass || type.pluginTypeId != "PostgreSQL" { SecureField( String(localized: "Password"), text: $password ) } - if usePgpass && (type == .postgresql || type == .redshift) { + if usePgpass && type.pluginTypeId == "PostgreSQL" { pgpassStatusView } } @@ -749,9 +750,17 @@ struct ConnectionFormView: View { return port == 0 ? "" : String(port) } + private var filePathPrompt: String { + let extensions = PluginManager.shared.driverPlugin(for: type) + .map { Swift.type(of: $0).fileExtensions } ?? [] + let ext = extensions.first ?? type.rawValue.lowercased() + return "/path/to/database.\(ext)" + } + private var isValid: Bool { // Host and port can be empty (will use defaults: localhost and default port) - let basicValid = !name.isEmpty && (type == .sqlite || type == .duckdb ? !database.isEmpty : true) + let isFileBased = PluginManager.shared.connectionMode(for: type) == .fileBased + let basicValid = !name.isEmpty && (isFileBased ? !database.isEmpty : true) if sshEnabled { let sshValid = !sshHost.isEmpty && !sshUsername.isEmpty let authValid = @@ -764,7 +773,7 @@ struct ConnectionFormView: View { } private func updatePgpassStatus() { - guard usePgpass, type == .postgresql || type == .redshift else { + guard usePgpass, type.pluginTypeId == "PostgreSQL" else { pgpassStatus = .notChecked return } @@ -816,7 +825,7 @@ struct ConnectionFormView: View { additionalFieldValues = existing.additionalFields // Migrate legacy Redis database index before default seeding - if existing.type == .redis, + if existing.type.pluginTypeId == "Redis", additionalFieldValues["redisDatabase"] == nil, let rdb = existing.redisDatabase { additionalFieldValues["redisDatabase"] = String(rdb) @@ -869,16 +878,15 @@ struct ConnectionFormView: View { clientKeyPath: sslClientKeyPath ) - // Apply defaults: localhost for empty host, default port for empty/invalid port, root for empty username - // MongoDB and SQLite commonly run without authentication, so skip the "root" default let finalHost = host.trimmingCharacters(in: .whitespaces).isEmpty ? "localhost" : host let finalPort = Int(port) ?? type.defaultPort let trimmedUsername = username.trimmingCharacters(in: .whitespaces) let finalUsername = - trimmedUsername.isEmpty && type.requiresAuthentication ? "root" : trimmedUsername + trimmedUsername.isEmpty && PluginManager.shared.requiresAuthentication(for: type) + ? "root" : trimmedUsername var finalAdditionalFields = additionalFieldValues - if usePgpass && (type == .postgresql || type == .redshift) { + if usePgpass && type.pluginTypeId == "PostgreSQL" { finalAdditionalFields["usePgpass"] = "true" } else { finalAdditionalFields.removeValue(forKey: "usePgpass") @@ -905,7 +913,7 @@ struct ConnectionFormView: View { groupId: selectedGroupId, safeModeLevel: safeModeLevel, aiPolicy: aiPolicy, - redisDatabase: type == .redis + redisDatabase: type.pluginTypeId == "Redis" ? Int(additionalFieldValues["redisDatabase"] ?? "0") : nil, startupCommands: startupCommands.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty @@ -1029,17 +1037,15 @@ struct ConnectionFormView: View { clientKeyPath: sslClientKeyPath ) - // Apply defaults: localhost for empty host, default port for empty/invalid port, root for empty username - // MongoDB and SQLite commonly run without authentication, so skip the "root" default let finalHost = host.trimmingCharacters(in: .whitespaces).isEmpty ? "localhost" : host let finalPort = Int(port) ?? type.defaultPort let trimmedUsername = username.trimmingCharacters(in: .whitespaces) let finalUsername = - trimmedUsername.isEmpty && type.requiresAuthentication ? "root" : trimmedUsername + trimmedUsername.isEmpty && PluginManager.shared.requiresAuthentication(for: type) + ? "root" : trimmedUsername - // Build finalAdditionalFields for test connection var finalAdditionalFields = additionalFieldValues - if usePgpass && (type == .postgresql || type == .redshift) { + if usePgpass && type.pluginTypeId == "PostgreSQL" { finalAdditionalFields["usePgpass"] = "true" } else { finalAdditionalFields.removeValue(forKey: "usePgpass") @@ -1051,7 +1057,6 @@ struct ConnectionFormView: View { finalAdditionalFields.removeValue(forKey: "preConnectScript") } - // Build connection from form values let testConn = DatabaseConnection( name: name, host: finalHost, @@ -1064,7 +1069,7 @@ struct ConnectionFormView: View { color: connectionColor, tagId: selectedTagId, groupId: selectedGroupId, - redisDatabase: type == .redis + redisDatabase: type.pluginTypeId == "Redis" ? Int(additionalFieldValues["redisDatabase"] ?? "0") : nil, startupCommands: startupCommands.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty @@ -1210,7 +1215,7 @@ struct ConnectionFormView: View { if let authSourceValue = parsed.authSource, !authSourceValue.isEmpty { additionalFieldValues["mongoAuthSource"] = authSourceValue } - if parsed.type == .redis, !parsed.database.isEmpty { + if parsed.type.pluginTypeId == "Redis", !parsed.database.isEmpty { additionalFieldValues["redisDatabase"] = parsed.database } if let connectionName = parsed.connectionName, !connectionName.isEmpty { diff --git a/TablePro/Views/Editor/SQLEditorView.swift b/TablePro/Views/Editor/SQLEditorView.swift index a2e03b89..7c11e56f 100644 --- a/TablePro/Views/Editor/SQLEditorView.swift +++ b/TablePro/Views/Editor/SQLEditorView.swift @@ -37,7 +37,7 @@ struct SQLEditorView: View { if editorReady { SourceEditor( $text, - language: databaseType == .mongodb ? .javascript : databaseType == .redis ? .bash : .sql, + language: PluginManager.shared.editorLanguage(for: databaseType ?? .mysql).treeSitterLanguage, configuration: editorConfiguration, state: $editorState, coordinators: [coordinator], diff --git a/TablePro/Views/Filter/FilterPanelView.swift b/TablePro/Views/Filter/FilterPanelView.swift index 9344f29e..cb14289d 100644 --- a/TablePro/Views/Filter/FilterPanelView.swift +++ b/TablePro/Views/Filter/FilterPanelView.swift @@ -231,7 +231,7 @@ struct FilterPanelView: View { .controlSize(.small) .disabled(!filterState.hasAppliedFilters) - Button(databaseType == .mongodb ? "MQL" : databaseType == .redis ? "CMD" : "SQL") { + Button(PluginManager.shared.queryLanguageName(for: databaseType)) { generatedSQL = filterState.generatePreviewSQL(databaseType: databaseType) showSQLSheet = true } diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 551d70b5..fb4025b5 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -516,7 +516,7 @@ struct MainEditorContentView: View { RoundedRectangle(cornerRadius: 4) .fill(Color(nsColor: .quaternaryLabelColor)) ) - Text(connection.type == .mongodb ? "Open MQL Editor" : connection.type == .redis ? "Open Redis CLI" : "Open SQL Editor") + Text("Open \(PluginManager.shared.queryLanguageName(for: connection.type)) Editor") .font(.callout) .foregroundStyle(.tertiary) } diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 78805b21..cb4b1d31 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -125,7 +125,7 @@ struct MainContentView: View { let session = DatabaseManager.shared.session(for: connection.id) let activeDatabase = session?.currentDatabase ?? connection.database let activeSchema = session?.currentSchema - let currentSelection = connection.type == .redshift + let currentSelection = PluginManager.shared.supportsSchemaSwitching(for: connection.type) ? (activeSchema ?? activeDatabase) : activeDatabase DatabaseSwitcherSheet( @@ -620,7 +620,8 @@ struct MainContentView: View { ) // Update window title to reflect selected tab - let queryLabel = connection.type == .mongodb ? "MQL Query" : connection.type == .redis ? "Redis Query" : "SQL Query" + let langName = PluginManager.shared.queryLanguageName(for: connection.type) + let queryLabel = langName == "SQL" ? "SQL Query" : langName == "MQL" ? "MQL Query" : langName windowTitle = tabManager.selectedTab?.tableName ?? (tabManager.tabs.isEmpty ? connection.name : queryLabel) @@ -639,7 +640,8 @@ struct MainContentView: View { private func handleTabsChange(_ newTabs: [QueryTab]) { // Always update window title to reflect current tab, even during restoration - let queryLabel = connection.type == .mongodb ? "MQL Query" : connection.type == .redis ? "Redis Query" : "SQL Query" + let langName = PluginManager.shared.queryLanguageName(for: connection.type) + let queryLabel = langName == "SQL" ? "SQL Query" : langName == "MQL" ? "MQL Query" : langName windowTitle = tabManager.selectedTab?.tableName ?? (tabManager.tabs.isEmpty ? connection.name : queryLabel) diff --git a/TablePro/Views/Sidebar/SidebarContextMenu.swift b/TablePro/Views/Sidebar/SidebarContextMenu.swift index b0db1d42..ad85ed61 100644 --- a/TablePro/Views/Sidebar/SidebarContextMenu.swift +++ b/TablePro/Views/Sidebar/SidebarContextMenu.swift @@ -6,6 +6,7 @@ // import SwiftUI +import TableProPluginKit /// Extracted logic from SidebarContextMenu for testability enum SidebarContextMenuLogic { @@ -17,8 +18,8 @@ enum SidebarContextMenuLogic { clickedTable?.type == .view } - static func importVisible(isView: Bool, isMongoDB: Bool) -> Bool { - !isView && !isMongoDB + static func importVisible(isView: Bool, editorLanguage: EditorLanguage) -> Bool { + !isView && editorLanguage == .sql } static func truncateVisible(isView: Bool) -> Bool { @@ -92,7 +93,7 @@ struct SidebarContextMenu: View { .keyboardShortcut("e", modifiers: [.command, .shift]) .disabled(!hasSelection) - if !isView && !AppState.shared.isMongoDB && !AppState.shared.isRedis { + if !isView && AppState.shared.editorLanguage == .sql { Button("Import...") { coordinator?.openImportDialog() } diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index 44f1db83..abbfcd98 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -151,20 +151,23 @@ struct SidebarView: View { } private var emptyState: some View { - VStack(spacing: 6) { + let entityName = PluginManager.shared.queryLanguageName(for: viewModel.databaseType) + let noItemsLabel = entityName == "MQL" ? "No Collections" + : entityName == "Redis CLI" ? "No Databases" + : "No Tables" + let noItemsDetail = entityName == "MQL" ? "This database has no collections yet." + : entityName == "Redis CLI" ? "All databases are empty." + : "This database has no tables yet." + return VStack(spacing: 6) { Image(systemName: "tablecells") .font(.system(size: 28, weight: .thin)) .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) - Text(sidebarLabel(mongodb: "No Collections", redis: "No Databases", default: "No Tables")) + Text(noItemsLabel) .font(.system(size: 13, weight: .medium)) .foregroundStyle(Color(nsColor: .secondaryLabelColor)) - Text(sidebarLabel( - mongodb: "This database has no collections yet.", - redis: "All databases are empty.", - default: "This database has no tables yet." - )) + Text(noItemsDetail) .font(.system(size: 11)) .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) } @@ -174,10 +177,21 @@ struct SidebarView: View { // MARK: - Table List private var tableList: some View { - List(selection: selectedTablesBinding) { + let langName = PluginManager.shared.queryLanguageName(for: viewModel.databaseType) + let entityLabel = langName == "MQL" ? "Collections" : langName == "Redis CLI" ? "Databases" : "Tables" + let noMatchLabel = langName == "MQL" ? "No matching collections" + : langName == "Redis CLI" ? "No matching databases" + : "No matching tables" + let helpLabel = langName == "MQL" ? "Right-click to show all collections" + : langName == "Redis CLI" ? "Right-click to show all databases" + : "Right-click to show all tables" + let showAllLabel = langName == "MQL" ? String(localized: "Show All Collections") + : langName == "Redis CLI" ? String(localized: "Show All Databases") + : String(localized: "Show All Tables") + return List(selection: selectedTablesBinding) { if filteredTables.isEmpty { ContentUnavailableView( - sidebarLabel(mongodb: "No matching collections", redis: "No matching databases", default: "No matching tables"), + noMatchLabel, systemImage: "magnifyingglass" ) .listRowSeparator(.hidden) @@ -209,18 +223,10 @@ struct SidebarView: View { } } } header: { - Text(sidebarLabel(mongodb: "Collections", redis: "Databases", default: "Tables")) - .help(sidebarLabel( - mongodb: "Right-click to show all collections", - redis: "Right-click to show all databases", - default: "Right-click to show all tables" - )) + Text(entityLabel) + .help(helpLabel) .contextMenu { - Button(sidebarLabel( - mongodb: String(localized: "Show All Collections"), - redis: String(localized: "Show All Databases"), - default: String(localized: "Show All Tables") - )) { + Button(showAllLabel) { coordinator?.showAllTablesMetadata() } } @@ -244,15 +250,6 @@ struct SidebarView: View { } } - // MARK: - Helpers - - private func sidebarLabel(mongodb: String, redis: String, default defaultLabel: String) -> String { - switch viewModel.databaseType { - case .mongodb: return mongodb - case .redis: return redis - default: return defaultLabel - } - } } // MARK: - Preview diff --git a/TablePro/Views/Sidebar/TableOperationDialog.swift b/TablePro/Views/Sidebar/TableOperationDialog.swift index b1b1d763..4f1366a6 100644 --- a/TablePro/Views/Sidebar/TableOperationDialog.swift +++ b/TablePro/Views/Sidebar/TableOperationDialog.swift @@ -43,14 +43,7 @@ struct TableOperationDialog: View { } private var cascadeSupported: Bool { - // PostgreSQL supports CASCADE for both DROP and TRUNCATE. - // MySQL, MariaDB, and SQLite do not support CASCADE for these operations. - switch databaseType { - case .postgresql, .redshift: - return true - default: - return false - } + PluginManager.shared.supportsCascadeDrop(for: databaseType) } private var isMultipleTables: Bool { @@ -62,32 +55,30 @@ struct TableOperationDialog: View { case .drop: return "Drop all tables that depend on this table" case .truncate: - if databaseType == .mysql || databaseType == .mariadb { - return "Not supported for TRUNCATE in MySQL/MariaDB" + if !cascadeSupported { + return "Not supported for TRUNCATE with this database" } return "Truncate all tables linked by foreign keys" } } private var cascadeDisabled: Bool { - // MySQL/MariaDB don't support CASCADE for TRUNCATE - if operationType == .truncate && (databaseType == .mysql || databaseType == .mariadb) { + if operationType == .truncate && !cascadeSupported { return true } return !cascadeSupported } - /// PostgreSQL doesn't support globally disabling FK checks; use CASCADE instead private var ignoreFKDisabled: Bool { - databaseType == .postgresql || databaseType == .redshift || databaseType == .oracle + !PluginManager.shared.supportsForeignKeyDisable(for: databaseType) } private var ignoreFKDescription: String? { - if databaseType == .postgresql || databaseType == .redshift { - return "Not supported for PostgreSQL. Use CASCADE instead." - } - if databaseType == .oracle { - return "Not supported for Oracle." + if !PluginManager.shared.supportsForeignKeyDisable(for: databaseType) { + if cascadeSupported { + return "Not supported for this database. Use CASCADE instead." + } + return "Not supported for this database." } return nil } diff --git a/TablePro/Views/Structure/TypePickerContentView.swift b/TablePro/Views/Structure/TypePickerContentView.swift index 8f8586d9..8387fff5 100644 --- a/TablePro/Views/Structure/TypePickerContentView.swift +++ b/TablePro/Views/Structure/TypePickerContentView.swift @@ -7,89 +7,6 @@ import SwiftUI -/// Data type categories for type picker -enum DataTypeCategory: String, CaseIterable { - case numeric = "Numeric" - case string = "String" - case dateTime = "Date & Time" - case binary = "Binary" - case other = "Other" - - func types(for dbType: DatabaseType) -> [String] { - Self.typeMap[self]?[dbType] ?? [] - } - - // swiftlint:disable:next line_length - private static let typeMap: [DataTypeCategory: [DatabaseType: [String]]] = [ - .numeric: [ - .mysql: ["TINYINT", "SMALLINT", "MEDIUMINT", "INT", "BIGINT", "DECIMAL", "NUMERIC", "FLOAT", "DOUBLE", "BIT"], - .mariadb: ["TINYINT", "SMALLINT", "MEDIUMINT", "INT", "BIGINT", "DECIMAL", "NUMERIC", "FLOAT", "DOUBLE", "BIT"], - .postgresql: ["SMALLINT", "INTEGER", "BIGINT", "DECIMAL", "NUMERIC", "REAL", "DOUBLE PRECISION", "SMALLSERIAL", "SERIAL", "BIGSERIAL"], - .redshift: ["SMALLINT", "INTEGER", "BIGINT", "DECIMAL", "NUMERIC", "REAL", "DOUBLE PRECISION", "SMALLSERIAL", "SERIAL", "BIGSERIAL"], - .mssql: ["TINYINT", "SMALLINT", "INT", "BIGINT", "DECIMAL", "NUMERIC", "FLOAT", "REAL", "MONEY", "SMALLMONEY", "BIT"], - .oracle: ["NUMBER", "BINARY_FLOAT", "BINARY_DOUBLE", "INTEGER", "SMALLINT", "FLOAT"], - .clickhouse: [ - "UInt8", "UInt16", "UInt32", "UInt64", "UInt128", "UInt256", - "Int8", "Int16", "Int32", "Int64", "Int128", "Int256", - "Float32", "Float64", "Decimal", "Decimal32", "Decimal64", "Decimal128", "Decimal256", "Bool", - ], - .sqlite: ["INTEGER", "REAL", "NUMERIC"], - .duckdb: ["INTEGER", "BIGINT", "HUGEINT", "SMALLINT", "TINYINT", "DOUBLE", "FLOAT", "DECIMAL", "REAL", "NUMERIC"], - .mongodb: ["Int32", "Int64", "Double", "Decimal128"], - .redis: ["Integer"], - ], - .string: [ - .mysql: ["CHAR", "VARCHAR", "TINYTEXT", "TEXT", "MEDIUMTEXT", "LONGTEXT"], - .mariadb: ["CHAR", "VARCHAR", "TINYTEXT", "TEXT", "MEDIUMTEXT", "LONGTEXT"], - .postgresql: ["CHAR", "VARCHAR", "TEXT"], - .redshift: ["CHAR", "VARCHAR", "TEXT"], - .mssql: ["CHAR", "VARCHAR", "NCHAR", "NVARCHAR", "TEXT", "NTEXT"], - .oracle: ["CHAR", "VARCHAR2", "NCHAR", "NVARCHAR2", "CLOB", "NCLOB", "LONG"], - .clickhouse: ["String", "FixedString", "UUID", "IPv4", "IPv6"], - .sqlite: ["TEXT"], - .duckdb: ["VARCHAR", "TEXT", "CHAR", "BPCHAR"], - .mongodb: ["String", "ObjectId", "UUID"], - .redis: ["String"], - ], - .dateTime: [ - .mysql: ["DATE", "TIME", "DATETIME", "TIMESTAMP", "YEAR"], - .mariadb: ["DATE", "TIME", "DATETIME", "TIMESTAMP", "YEAR"], - .postgresql: ["DATE", "TIME", "TIMESTAMP", "TIMESTAMPTZ", "INTERVAL"], - .redshift: ["DATE", "TIME", "TIMESTAMP", "TIMESTAMPTZ", "INTERVAL"], - .mssql: ["DATE", "TIME", "DATETIME", "DATETIME2", "SMALLDATETIME", "DATETIMEOFFSET"], - .oracle: ["DATE", "TIMESTAMP", "TIMESTAMP WITH TIME ZONE", "TIMESTAMP WITH LOCAL TIME ZONE", "INTERVAL YEAR TO MONTH", "INTERVAL DAY TO SECOND"], - .clickhouse: ["Date", "Date32", "DateTime", "DateTime64"], - .sqlite: ["DATE", "DATETIME"], - .duckdb: ["DATE", "TIME", "TIMESTAMP", "TIMESTAMP WITH TIME ZONE", "INTERVAL"], - .mongodb: ["Date", "Timestamp"], - ], - .binary: [ - .mysql: ["BINARY", "VARBINARY", "TINYBLOB", "BLOB", "MEDIUMBLOB", "LONGBLOB"], - .mariadb: ["BINARY", "VARBINARY", "TINYBLOB", "BLOB", "MEDIUMBLOB", "LONGBLOB"], - .postgresql: ["BYTEA"], - .redshift: ["BYTEA"], - .mssql: ["BINARY", "VARBINARY", "IMAGE"], - .oracle: ["BLOB", "RAW", "LONG RAW", "BFILE"], - .sqlite: ["BLOB"], - .duckdb: ["BLOB", "BYTEA"], - .mongodb: ["BinData"], - ], - .other: [ - .mysql: ["BOOLEAN", "ENUM", "SET", "JSON"], - .mariadb: ["BOOLEAN", "ENUM", "SET", "JSON"], - .postgresql: ["BOOLEAN", "UUID", "JSON", "JSONB", "ARRAY", "HSTORE", "INET", "CIDR", "MACADDR", "TSVECTOR", "TSQUERY"], - .redshift: ["BOOLEAN", "UUID", "JSON", "JSONB", "ARRAY", "HSTORE", "INET", "CIDR", "MACADDR", "TSVECTOR", "TSQUERY"], - .mssql: ["BIT", "UNIQUEIDENTIFIER", "XML", "SQL_VARIANT", "ROWVERSION", "HIERARCHYID"], - .oracle: ["BOOLEAN", "ROWID", "UROWID", "XMLTYPE", "SDO_GEOMETRY"], - .clickhouse: ["Array", "Tuple", "Map", "Nested", "JSON", "Nullable", "LowCardinality", "Enum8", "Enum16", "Nothing"], - .sqlite: ["BOOLEAN"], - .duckdb: ["BOOLEAN", "UUID", "JSON", "LIST", "MAP", "STRUCT", "ENUM", "BIT", "UNION"], - .mongodb: ["Boolean", "Object", "Array", "Null", "Regex"], - .redis: ["List", "Set", "Sorted Set", "Hash", "Stream"], - ], - ] -} - struct TypePickerContentView: View { let databaseType: DatabaseType let currentValue: String @@ -103,19 +20,27 @@ struct TypePickerContentView: View { private static let searchAreaHeight: CGFloat = 44 private static let maxTotalHeight: CGFloat = 360 - private var visibleCategories: [DataTypeCategory] { - DataTypeCategory.allCases.filter { !filteredTypes(for: $0).isEmpty } + private var allCategories: [(name: String, types: [String])] { + PluginManager.shared.columnTypesByCategory(for: databaseType) + .sorted { $0.key < $1.key } + .map { (name: $0.key, types: $0.value) } + } + + private var visibleCategories: [(name: String, types: [String])] { + allCategories.compactMap { category in + let filtered = filteredTypes(from: category.types) + return filtered.isEmpty ? nil : (name: category.name, types: filtered) + } } - private func filteredTypes(for category: DataTypeCategory) -> [String] { - let types = category.types(for: databaseType) + private func filteredTypes(from types: [String]) -> [String] { if searchText.isEmpty { return types } let query = searchText.lowercased() return types.filter { $0.lowercased().contains(query) } } private var totalFilteredCount: Int { - visibleCategories.reduce(0) { $0 + filteredTypes(for: $1).count } + visibleCategories.reduce(0) { $0 + $1.types.count } } private var listHeight: CGFloat { @@ -137,9 +62,9 @@ struct TypePickerContentView: View { Divider() List { - ForEach(visibleCategories, id: \.self) { category in - Section(header: Text(category.rawValue)) { - ForEach(filteredTypes(for: category), id: \.self) { type in + ForEach(visibleCategories, id: \.name) { category in + Section(header: Text(category.name)) { + ForEach(category.types, id: \.self) { type in typeRow(type) .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) diff --git a/TablePro/Views/Toolbar/ConnectionStatusView.swift b/TablePro/Views/Toolbar/ConnectionStatusView.swift index 680f5702..ff8e490a 100644 --- a/TablePro/Views/Toolbar/ConnectionStatusView.swift +++ b/TablePro/Views/Toolbar/ConnectionStatusView.swift @@ -51,7 +51,7 @@ struct ConnectionStatusView: View { /// Database name (clickable to open database switcher, plain label for SQLite) @ViewBuilder private var databaseNameSection: some View { - if databaseType == .sqlite || databaseType == .duckdb { + if !PluginManager.shared.supportsDatabaseSwitching(for: databaseType) { databaseNameLabel .help("Database: \(databaseName)") } else { diff --git a/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift b/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift index c66e0371..1d7f5e7d 100644 --- a/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift +++ b/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift @@ -8,6 +8,7 @@ import AppKit import SwiftUI +import TableProPluginKit /// Popover content for quick connection switching struct ConnectionSwitcherPopover: View { @@ -293,7 +294,7 @@ struct ConnectionSwitcherPopover: View { // MARK: - Helpers private func connectionSubtitle(_ connection: DatabaseConnection) -> String { - if connection.type == .sqlite || connection.type == .duckdb { + if PluginManager.shared.connectionMode(for: connection.type) == .fileBased { return connection.database } let port = connection.port != connection.type.defaultPort ? ":\(connection.port)" : "" diff --git a/TablePro/Views/Toolbar/TableProToolbarView.swift b/TablePro/Views/Toolbar/TableProToolbarView.swift index 91a244e6..3404aaaf 100644 --- a/TablePro/Views/Toolbar/TableProToolbarView.swift +++ b/TablePro/Views/Toolbar/TableProToolbarView.swift @@ -12,6 +12,7 @@ // import SwiftUI +import TableProPluginKit /// Content for the principal (center) toolbar area /// Displays environment badge, connection status, and execution indicator in a unified card @@ -61,7 +62,7 @@ struct TableProToolbar: ViewModifier { .toolbar { // MARK: - Navigation (Left) - if state.databaseType != .redis { + if PluginManager.shared.supportsDatabaseSwitching(for: state.databaseType) { ToolbarItem(placement: .navigation) { Button { showConnectionSwitcher.toggle() @@ -84,7 +85,8 @@ struct TableProToolbar: ViewModifier { } .help("Open Database (⌘K)") .disabled( - state.connectionState != .connected || state.databaseType == .sqlite || state.databaseType == .duckdb) + state.connectionState != .connected + || PluginManager.shared.connectionMode(for: state.databaseType) == .fileBased) } } @@ -139,15 +141,18 @@ struct TableProToolbar: ViewModifier { Button { actions?.previewSQL() } label: { - Label( - state.databaseType == .mongodb ? "Preview MQL" - : state.databaseType == .redis ? "Preview Commands" - : "Preview SQL", - systemImage: "eye") + let langName = PluginManager.shared.queryLanguageName(for: state.databaseType) + let previewLabel = langName == "SQL" ? "Preview SQL" + : langName == "MQL" ? "Preview MQL" + : "Preview Commands" + Label(previewLabel, systemImage: "eye") } - .help(state.databaseType == .mongodb ? "Preview MQL (⌘⇧P)" - : state.databaseType == .redis ? "Preview Commands (⌘⇧P)" - : "Preview SQL (⌘⇧P)") + .help({ + let langName = PluginManager.shared.queryLanguageName(for: state.databaseType) + return langName == "SQL" ? "Preview SQL (⌘⇧P)" + : langName == "MQL" ? "Preview MQL (⌘⇧P)" + : "Preview Commands (⌘⇧P)" + }()) .disabled(!state.hasPendingChanges || state.connectionState != .connected) .popover(isPresented: $state.showSQLReviewPopover) { SQLReviewPopover(statements: state.previewStatements, databaseType: state.databaseType) @@ -181,7 +186,7 @@ struct TableProToolbar: ViewModifier { .help("Export Data (⌘⇧E)") .disabled(state.connectionState != .connected) - if state.databaseType != .mongodb && state.databaseType != .redis { + if PluginManager.shared.supportsImport(for: state.databaseType) { Button { actions?.importTables() } label: { diff --git a/TableProTests/Models/DatabaseTypeRedisTests.swift b/TableProTests/Models/DatabaseTypeRedisTests.swift index 642dd5f8..5ace3b5b 100644 --- a/TableProTests/Models/DatabaseTypeRedisTests.swift +++ b/TableProTests/Models/DatabaseTypeRedisTests.swift @@ -33,9 +33,9 @@ struct DatabaseTypeRedisTests { #expect(DatabaseType.redis.rawValue == "Redis") } - @Test("Theme color matches Theme.redisColor") - func themeColor() { - #expect(DatabaseType.redis.themeColor == Theme.redisColor) + @Test("Theme color is derived from plugin brand color") + @MainActor func themeColor() { + #expect(DatabaseType.redis.themeColor == PluginManager.shared.brandColor(for: .redis)) } @Test("Included in allKnownTypes") diff --git a/TableProTests/Views/SidebarContextMenuLogicTests.swift b/TableProTests/Views/SidebarContextMenuLogicTests.swift index b370691f..900fad36 100644 --- a/TableProTests/Views/SidebarContextMenuLogicTests.swift +++ b/TableProTests/Views/SidebarContextMenuLogicTests.swift @@ -6,6 +6,7 @@ // import SwiftUI +import TableProPluginKit import Testing @testable import TablePro @@ -59,19 +60,19 @@ struct SidebarContextMenuLogicTests { // MARK: - Import Visibility - @Test("Import visible for table, not MongoDB") + @Test("Import visible for table with SQL editor") func importVisibleForTable() { - #expect(SidebarContextMenuLogic.importVisible(isView: false, isMongoDB: false)) + #expect(SidebarContextMenuLogic.importVisible(isView: false, editorLanguage: .sql)) } @Test("Import hidden for view") func importHiddenForView() { - #expect(!SidebarContextMenuLogic.importVisible(isView: true, isMongoDB: false)) + #expect(!SidebarContextMenuLogic.importVisible(isView: true, editorLanguage: .sql)) } - @Test("Import hidden for MongoDB") - func importHiddenForMongoDB() { - #expect(!SidebarContextMenuLogic.importVisible(isView: false, isMongoDB: true)) + @Test("Import hidden for non-SQL editor") + func importHiddenForNonSQL() { + #expect(!SidebarContextMenuLogic.importVisible(isView: false, editorLanguage: .javascript)) } // MARK: - Truncate Visibility From 7d63c22fa6e99d3f7580378f8ac3fc5359b9327e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 13 Mar 2026 11:29:03 +0700 Subject: [PATCH 2/4] fix: address PR review feedback for plugin metadata lookups --- TablePro/AppDelegate+ConnectionHandler.swift | 47 +++++++++++++++++++ TablePro/AppDelegate+FileOpen.swift | 2 +- TablePro/ContentView.swift | 5 ++ TablePro/Core/Plugins/PluginManager.swift | 12 ++++- TablePro/Extensions/Color+Hex.swift | 13 +++-- .../EditorLanguage+TreeSitter.swift | 2 +- TablePro/TableProApp.swift | 13 +++-- .../Views/Components/SQLReviewPopover.swift | 2 +- .../Views/Connection/ConnectionFormView.swift | 2 +- TablePro/Views/Sidebar/SidebarView.swift | 25 +++------- .../Views/Sidebar/TableOperationDialog.swift | 6 +-- .../Views/Toolbar/TableProToolbarView.swift | 36 ++++++-------- 12 files changed, 107 insertions(+), 58 deletions(-) diff --git a/TablePro/AppDelegate+ConnectionHandler.swift b/TablePro/AppDelegate+ConnectionHandler.swift index 92ec8893..0fe711d1 100644 --- a/TablePro/AppDelegate+ConnectionHandler.swift +++ b/TablePro/AppDelegate+ConnectionHandler.swift @@ -16,6 +16,7 @@ enum QueuedURLEntry { case databaseURL(URL) case sqliteFile(URL) case duckdbFile(URL) + case genericDatabaseFile(URL, DatabaseType) } extension AppDelegate { @@ -172,6 +173,51 @@ extension AppDelegate { } } + // MARK: - Generic Database File Handler + + func handleGenericDatabaseFile(_ url: URL, type dbType: DatabaseType) { + guard WindowOpener.shared.openWindow != nil else { + queuedURLEntries.append(.genericDatabaseFile(url, dbType)) + scheduleQueuedURLProcessing() + return + } + + let filePath = url.path(percentEncoded: false) + let connectionName = url.deletingPathExtension().lastPathComponent + + for (sessionId, session) in DatabaseManager.shared.activeSessions { + if session.connection.type == dbType + && session.connection.database == filePath + && session.driver != nil { + bringConnectionWindowToFront(sessionId) + return + } + } + + let connection = DatabaseConnection( + name: connectionName, + host: "", + port: 0, + database: filePath, + username: "", + type: dbType + ) + + openNewConnectionWindow(for: connection) + + Task { @MainActor in + do { + try await DatabaseManager.shared.connectToSession(connection) + for window in NSApp.windows where self.isWelcomeWindow(window) { + window.close() + } + } catch { + connectionLogger.error("File open failed for '\(filePath, privacy: .public)' (\(dbType.rawValue)): \(error.localizedDescription)") + await self.handleConnectionFailure(error) + } + } + } + // MARK: - Unified Queue func scheduleQueuedURLProcessing() { @@ -203,6 +249,7 @@ extension AppDelegate { case .databaseURL(let url): self.handleDatabaseURL(url) case .sqliteFile(let url): self.handleSQLiteFile(url) case .duckdbFile(let url): self.handleDuckDBFile(url) + case .genericDatabaseFile(let url, let dbType): self.handleGenericDatabaseFile(url, type: dbType) } } self.scheduleWelcomeWindowSuppression() diff --git a/TablePro/AppDelegate+FileOpen.swift b/TablePro/AppDelegate+FileOpen.swift index af0be3d2..25c66fdb 100644 --- a/TablePro/AppDelegate+FileOpen.swift +++ b/TablePro/AppDelegate+FileOpen.swift @@ -69,7 +69,7 @@ extension AppDelegate { case .duckdb: self.handleDuckDBFile(url) default: - break + self.handleGenericDatabaseFile(url, type: dbType) } } self.scheduleWelcomeWindowSuppression() diff --git a/TablePro/ContentView.swift b/TablePro/ContentView.swift index 4e0c4f93..aa6d82ae 100644 --- a/TablePro/ContentView.swift +++ b/TablePro/ContentView.swift @@ -93,6 +93,7 @@ struct ContentView: View { AppState.shared.isConnected = true AppState.shared.safeModeLevel = session.connection.safeModeLevel AppState.shared.editorLanguage = PluginManager.shared.editorLanguage(for: session.connection.type) + AppState.shared.currentDatabaseType = session.connection.type AppState.shared.supportsDatabaseSwitching = PluginManager.shared.supportsDatabaseSwitching( for: session.connection.type) } @@ -119,6 +120,7 @@ struct ContentView: View { AppState.shared.isConnected = false AppState.shared.safeModeLevel = .silent AppState.shared.editorLanguage = .sql + AppState.shared.currentDatabaseType = nil AppState.shared.supportsDatabaseSwitching = true // Close all native tab windows for this connection and @@ -150,6 +152,7 @@ struct ContentView: View { AppState.shared.isConnected = true AppState.shared.safeModeLevel = newSession.connection.safeModeLevel AppState.shared.editorLanguage = PluginManager.shared.editorLanguage(for: newSession.connection.type) + AppState.shared.currentDatabaseType = newSession.connection.type AppState.shared.supportsDatabaseSwitching = PluginManager.shared.supportsDatabaseSwitching( for: newSession.connection.type) } @@ -179,12 +182,14 @@ struct ContentView: View { AppState.shared.isConnected = true AppState.shared.safeModeLevel = session.connection.safeModeLevel AppState.shared.editorLanguage = PluginManager.shared.editorLanguage(for: session.connection.type) + AppState.shared.currentDatabaseType = session.connection.type AppState.shared.supportsDatabaseSwitching = PluginManager.shared.supportsDatabaseSwitching( for: session.connection.type) } else { AppState.shared.isConnected = false AppState.shared.safeModeLevel = .silent AppState.shared.editorLanguage = .sql + AppState.shared.currentDatabaseType = nil AppState.shared.supportsDatabaseSwitching = true } } diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index 93eac522..07b7b350 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -461,10 +461,18 @@ final class PluginManager { var allRegisteredFileExtensions: [String: DatabaseType] { loadPendingPlugins() var result: [String: DatabaseType] = [:] - for (typeId, plugin) in driverPlugins { + for typeId in driverPlugins.keys.sorted() { + guard let plugin = driverPlugins[typeId] else { continue } let dbType = DatabaseType(rawValue: typeId) for ext in Swift.type(of: plugin).fileExtensions { - result[ext.lowercased()] = dbType + let key = ext.lowercased() + if let existing = result[key], existing != dbType { + Self.logger.warning( + "File extension '\(key)' is registered by multiple plugins; keeping '\(existing.rawValue)', ignoring '\(dbType.rawValue)'" + ) + continue + } + result[key] = dbType } } return result diff --git a/TablePro/Extensions/Color+Hex.swift b/TablePro/Extensions/Color+Hex.swift index ba99673f..c2c192c0 100644 --- a/TablePro/Extensions/Color+Hex.swift +++ b/TablePro/Extensions/Color+Hex.swift @@ -6,12 +6,15 @@ import SwiftUI extension Color { - /// Creates a Color from a hex string like "#FF8800" or "FF8800". init(hex: String) { - let hex = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#")) - let scanner = Scanner(string: hex) - var rgbValue: UInt64 = 0 - scanner.scanHexInt64(&rgbValue) + let cleaned = hex + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "#")) + + guard cleaned.count == 6, let rgbValue = UInt64(cleaned, radix: 16) else { + self = .gray + return + } let red = Double((rgbValue >> 16) & 0xFF) / 255.0 let green = Double((rgbValue >> 8) & 0xFF) / 255.0 diff --git a/TablePro/Extensions/EditorLanguage+TreeSitter.swift b/TablePro/Extensions/EditorLanguage+TreeSitter.swift index 164e39d5..f2d0b629 100644 --- a/TablePro/Extensions/EditorLanguage+TreeSitter.swift +++ b/TablePro/Extensions/EditorLanguage+TreeSitter.swift @@ -12,7 +12,7 @@ extension EditorLanguage { case .sql: return .sql case .javascript: return .javascript case .bash: return .bash - case .custom: return .sql + case .custom: return .default } } diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index 9940aefc..0dab94ae 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -21,6 +21,7 @@ final class AppState { var safeModeLevel: SafeModeLevel = .silent var isReadOnly: Bool { safeModeLevel.blocksAllWrites } var editorLanguage: EditorLanguage = .sql + var currentDatabaseType: DatabaseType? var supportsDatabaseSwitching: Bool = true var isCurrentTabEditable: Bool = false // True when current tab is an editable table var hasRowSelection: Bool = false // True when rows are selected in data grid @@ -194,10 +195,14 @@ struct AppMenuCommands: Commands { .optionalKeyboardShortcut(shortcut(for: .saveChanges)) .disabled(!appState.isConnected || appState.isReadOnly) - Button(appState.editorLanguage == .javascript ? "Preview MQL" - : appState.editorLanguage == .bash ? "Preview Commands" - : "Preview SQL") { + Button { actions?.previewSQL() + } label: { + if let dbType = appState.currentDatabaseType { + Text("Preview \(PluginManager.shared.queryLanguageName(for: dbType))") + } else { + Text("Preview SQL") + } } .optionalKeyboardShortcut(shortcut(for: .previewSQL)) .disabled(!appState.isConnected) @@ -233,7 +238,7 @@ struct AppMenuCommands: Commands { .optionalKeyboardShortcut(shortcut(for: .export)) .disabled(!appState.isConnected) - if appState.editorLanguage == .sql { + if appState.currentDatabaseType.map({ PluginManager.shared.supportsImport(for: $0) }) ?? true { Button("Import...") { actions?.importTables() } diff --git a/TablePro/Views/Components/SQLReviewPopover.swift b/TablePro/Views/Components/SQLReviewPopover.swift index 0eeafcc6..ec814793 100644 --- a/TablePro/Views/Components/SQLReviewPopover.swift +++ b/TablePro/Views/Components/SQLReviewPopover.swift @@ -100,7 +100,7 @@ struct SQLReviewPopover: View { private var headerView: some View { HStack { - Text(String(localized: "\(PluginManager.shared.queryLanguageName(for: databaseType)) Preview")) + Text("\(PluginManager.shared.queryLanguageName(for: databaseType)) Preview") .font(.system(size: DesignConstants.FontSize.body, weight: .semibold)) if !statements.isEmpty { Text( diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index 4fc56933..7f379c22 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -753,7 +753,7 @@ struct ConnectionFormView: View { private var filePathPrompt: String { let extensions = PluginManager.shared.driverPlugin(for: type) .map { Swift.type(of: $0).fileExtensions } ?? [] - let ext = extensions.first ?? type.rawValue.lowercased() + let ext = extensions.first ?? "db" return "/path/to/database.\(ext)" } diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index abbfcd98..75cb48bd 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -151,13 +151,9 @@ struct SidebarView: View { } private var emptyState: some View { - let entityName = PluginManager.shared.queryLanguageName(for: viewModel.databaseType) - let noItemsLabel = entityName == "MQL" ? "No Collections" - : entityName == "Redis CLI" ? "No Databases" - : "No Tables" - let noItemsDetail = entityName == "MQL" ? "This database has no collections yet." - : entityName == "Redis CLI" ? "All databases are empty." - : "This database has no tables yet." + let entityName = PluginManager.shared.tableEntityName(for: viewModel.databaseType) + let noItemsLabel = String(localized: "No \(entityName)") + let noItemsDetail = String(localized: "This database has no \(entityName.lowercased()) yet.") return VStack(spacing: 6) { Image(systemName: "tablecells") .font(.system(size: 28, weight: .thin)) @@ -177,17 +173,10 @@ struct SidebarView: View { // MARK: - Table List private var tableList: some View { - let langName = PluginManager.shared.queryLanguageName(for: viewModel.databaseType) - let entityLabel = langName == "MQL" ? "Collections" : langName == "Redis CLI" ? "Databases" : "Tables" - let noMatchLabel = langName == "MQL" ? "No matching collections" - : langName == "Redis CLI" ? "No matching databases" - : "No matching tables" - let helpLabel = langName == "MQL" ? "Right-click to show all collections" - : langName == "Redis CLI" ? "Right-click to show all databases" - : "Right-click to show all tables" - let showAllLabel = langName == "MQL" ? String(localized: "Show All Collections") - : langName == "Redis CLI" ? String(localized: "Show All Databases") - : String(localized: "Show All Tables") + let entityLabel = PluginManager.shared.tableEntityName(for: viewModel.databaseType) + let noMatchLabel = String(localized: "No matching \(entityLabel.lowercased())") + let helpLabel = String(localized: "Right-click to show all \(entityLabel.lowercased())") + let showAllLabel = String(localized: "Show All \(entityLabel)") return List(selection: selectedTablesBinding) { if filteredTables.isEmpty { ContentUnavailableView( diff --git a/TablePro/Views/Sidebar/TableOperationDialog.swift b/TablePro/Views/Sidebar/TableOperationDialog.swift index 4f1366a6..af9029e0 100644 --- a/TablePro/Views/Sidebar/TableOperationDialog.swift +++ b/TablePro/Views/Sidebar/TableOperationDialog.swift @@ -56,7 +56,7 @@ struct TableOperationDialog: View { return "Drop all tables that depend on this table" case .truncate: if !cascadeSupported { - return "Not supported for TRUNCATE with this database" + return String(localized: "Not supported for TRUNCATE with this database") } return "Truncate all tables linked by foreign keys" } @@ -76,9 +76,9 @@ struct TableOperationDialog: View { private var ignoreFKDescription: String? { if !PluginManager.shared.supportsForeignKeyDisable(for: databaseType) { if cascadeSupported { - return "Not supported for this database. Use CASCADE instead." + return String(localized: "Not supported for this database. Use CASCADE instead.") } - return "Not supported for this database." + return String(localized: "Not supported for this database.") } return nil } diff --git a/TablePro/Views/Toolbar/TableProToolbarView.swift b/TablePro/Views/Toolbar/TableProToolbarView.swift index 3404aaaf..4058dd88 100644 --- a/TablePro/Views/Toolbar/TableProToolbarView.swift +++ b/TablePro/Views/Toolbar/TableProToolbarView.swift @@ -62,21 +62,21 @@ struct TableProToolbar: ViewModifier { .toolbar { // MARK: - Navigation (Left) - if PluginManager.shared.supportsDatabaseSwitching(for: state.databaseType) { - ToolbarItem(placement: .navigation) { - Button { - showConnectionSwitcher.toggle() - } label: { - Label("Connection", systemImage: "network") - } - .help("Switch Connection (⌘⌥C)") - .popover(isPresented: $showConnectionSwitcher) { - ConnectionSwitcherPopover { - showConnectionSwitcher = false - } + ToolbarItem(placement: .navigation) { + Button { + showConnectionSwitcher.toggle() + } label: { + Label("Connection", systemImage: "network") + } + .help("Switch Connection (⌘⌥C)") + .popover(isPresented: $showConnectionSwitcher) { + ConnectionSwitcherPopover { + showConnectionSwitcher = false } } + } + if PluginManager.shared.supportsDatabaseSwitching(for: state.databaseType) { ToolbarItem(placement: .navigation) { Button { actions?.openDatabaseSwitcher() @@ -142,17 +142,9 @@ struct TableProToolbar: ViewModifier { actions?.previewSQL() } label: { let langName = PluginManager.shared.queryLanguageName(for: state.databaseType) - let previewLabel = langName == "SQL" ? "Preview SQL" - : langName == "MQL" ? "Preview MQL" - : "Preview Commands" - Label(previewLabel, systemImage: "eye") + Label("Preview \(langName)", systemImage: "eye") } - .help({ - let langName = PluginManager.shared.queryLanguageName(for: state.databaseType) - return langName == "SQL" ? "Preview SQL (⌘⇧P)" - : langName == "MQL" ? "Preview MQL (⌘⇧P)" - : "Preview Commands (⌘⇧P)" - }()) + .help("Preview \(PluginManager.shared.queryLanguageName(for: state.databaseType)) (⌘⇧P)") .disabled(!state.hasPendingChanges || state.connectionState != .connected) .popover(isPresented: $state.showSQLReviewPopover) { SQLReviewPopover(statements: state.previewStatements, databaseType: state.databaseType) From 71114c7ea05e9fbf27c78f158258d2a81a54fe6b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 13 Mar 2026 11:40:22 +0700 Subject: [PATCH 3/4] fix: address PR review round 2 feedback - Fix Switch Connection button disabled state (connection switching always works) - Localize cascade description strings in TableOperationDialog - Sanitize file extension in ConnectionFormView filePathPrompt - Deduplicate aliased plugins via ObjectIdentifier in PluginManager --- TablePro/Core/Plugins/PluginManager.swift | 6 ++++++ TablePro/TableProApp.swift | 2 +- TablePro/Views/Connection/ConnectionFormView.swift | 4 +++- TablePro/Views/Sidebar/TableOperationDialog.swift | 4 ++-- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index 07b7b350..8e3669bc 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -461,8 +461,11 @@ final class PluginManager { var allRegisteredFileExtensions: [String: DatabaseType] { loadPendingPlugins() var result: [String: DatabaseType] = [:] + var seen = Set() for typeId in driverPlugins.keys.sorted() { guard let plugin = driverPlugins[typeId] else { continue } + let pluginId = ObjectIdentifier(Swift.type(of: plugin)) + guard seen.insert(pluginId).inserted else { continue } let dbType = DatabaseType(rawValue: typeId) for ext in Swift.type(of: plugin).fileExtensions { let key = ext.lowercased() @@ -482,7 +485,10 @@ final class PluginManager { var allRegisteredURLSchemes: Set { loadPendingPlugins() var result: Set = [] + var seen = Set() for plugin in driverPlugins.values { + let pluginId = ObjectIdentifier(Swift.type(of: plugin)) + guard seen.insert(pluginId).inserted else { continue } for scheme in Swift.type(of: plugin).urlSchemes { result.insert(scheme.lowercased()) } diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index 0dab94ae..f4fd579f 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -179,7 +179,7 @@ struct AppMenuCommands: Commands { NotificationCenter.default.post(name: .openConnectionSwitcher, object: nil) } .optionalKeyboardShortcut(shortcut(for: .switchConnection)) - .disabled(!appState.isConnected || !appState.supportsDatabaseSwitching) + .disabled(!appState.isConnected) Button("Quick Switcher...") { actions?.openQuickSwitcher() diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index 7f379c22..e1ef5610 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -753,7 +753,9 @@ struct ConnectionFormView: View { private var filePathPrompt: String { let extensions = PluginManager.shared.driverPlugin(for: type) .map { Swift.type(of: $0).fileExtensions } ?? [] - let ext = extensions.first ?? "db" + let ext = (extensions.first ?? "db") + .trimmingCharacters(in: CharacterSet(charactersIn: ". ")) + guard !ext.isEmpty else { return "/path/to/database.db" } return "/path/to/database.\(ext)" } diff --git a/TablePro/Views/Sidebar/TableOperationDialog.swift b/TablePro/Views/Sidebar/TableOperationDialog.swift index af9029e0..2d7fa492 100644 --- a/TablePro/Views/Sidebar/TableOperationDialog.swift +++ b/TablePro/Views/Sidebar/TableOperationDialog.swift @@ -53,12 +53,12 @@ struct TableOperationDialog: View { private var cascadeDescription: String { switch operationType { case .drop: - return "Drop all tables that depend on this table" + return String(localized: "Drop all tables that depend on this table") case .truncate: if !cascadeSupported { return String(localized: "Not supported for TRUNCATE with this database") } - return "Truncate all tables linked by foreign keys" + return String(localized: "Truncate all tables linked by foreign keys") } } From 3122a5aad91457fcc00982e2695078efceca2bc6 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 13 Mar 2026 11:50:52 +0700 Subject: [PATCH 4/4] fix: address code review issues in plugin metadata PR - Remove .db from DuckDB file extensions to avoid collision with SQLite - Use supportsImport plugin lookup instead of editorLanguage check in sidebar - Wire SidebarContextMenuLogic.importVisible into production view body - Simplify window title to unconditional "\(langName) Query" pattern - Update tests to match supportsImport-based import visibility --- Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift | 2 +- TablePro/ContentView.swift | 2 +- TablePro/Views/Main/MainContentView.swift | 4 ++-- TablePro/Views/Sidebar/SidebarContextMenu.swift | 11 ++++++++--- .../Views/SidebarContextMenuLogicTests.swift | 12 ++++++------ 5 files changed, 18 insertions(+), 13 deletions(-) diff --git a/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift b/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift index 85fa639b..28eb933d 100644 --- a/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift +++ b/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift @@ -24,7 +24,7 @@ final class DuckDBPlugin: NSObject, TableProPlugin, DriverPlugin { static let requiresAuthentication = false static let connectionMode: ConnectionMode = .fileBased static let urlSchemes: [String] = ["duckdb"] - static let fileExtensions: [String] = ["duckdb", "db"] + static let fileExtensions: [String] = ["duckdb", "ddb"] static let brandColorHex = "#FFD900" static let supportsDatabaseSwitching = false static let systemDatabaseNames: [String] = ["information_schema", "pg_catalog"] diff --git a/TablePro/ContentView.swift b/TablePro/ContentView.swift index aa6d82ae..9de6a7fd 100644 --- a/TablePro/ContentView.swift +++ b/TablePro/ContentView.swift @@ -42,7 +42,7 @@ struct ContentView: View { } else if let connectionId = payload?.connectionId, let connection = ConnectionStorage.shared.loadConnections().first(where: { $0.id == connectionId }) { let langName = PluginManager.shared.queryLanguageName(for: connection.type) - defaultTitle = langName == "SQL" ? "SQL Query" : langName == "MQL" ? "MQL Query" : langName + defaultTitle = "\(langName) Query" } else { defaultTitle = "SQL Query" } diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index cb4b1d31..af2d544e 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -621,7 +621,7 @@ struct MainContentView: View { // Update window title to reflect selected tab let langName = PluginManager.shared.queryLanguageName(for: connection.type) - let queryLabel = langName == "SQL" ? "SQL Query" : langName == "MQL" ? "MQL Query" : langName + let queryLabel = "\(langName) Query" windowTitle = tabManager.selectedTab?.tableName ?? (tabManager.tabs.isEmpty ? connection.name : queryLabel) @@ -641,7 +641,7 @@ struct MainContentView: View { private func handleTabsChange(_ newTabs: [QueryTab]) { // Always update window title to reflect current tab, even during restoration let langName = PluginManager.shared.queryLanguageName(for: connection.type) - let queryLabel = langName == "SQL" ? "SQL Query" : langName == "MQL" ? "MQL Query" : langName + let queryLabel = "\(langName) Query" windowTitle = tabManager.selectedTab?.tableName ?? (tabManager.tabs.isEmpty ? connection.name : queryLabel) diff --git a/TablePro/Views/Sidebar/SidebarContextMenu.swift b/TablePro/Views/Sidebar/SidebarContextMenu.swift index ad85ed61..b2ef06d6 100644 --- a/TablePro/Views/Sidebar/SidebarContextMenu.swift +++ b/TablePro/Views/Sidebar/SidebarContextMenu.swift @@ -18,8 +18,8 @@ enum SidebarContextMenuLogic { clickedTable?.type == .view } - static func importVisible(isView: Bool, editorLanguage: EditorLanguage) -> Bool { - !isView && editorLanguage == .sql + static func importVisible(isView: Bool, supportsImport: Bool) -> Bool { + !isView && supportsImport } static func truncateVisible(isView: Bool) -> Bool { @@ -93,7 +93,12 @@ struct SidebarContextMenu: View { .keyboardShortcut("e", modifiers: [.command, .shift]) .disabled(!hasSelection) - if !isView && AppState.shared.editorLanguage == .sql { + if SidebarContextMenuLogic.importVisible( + isView: isView, + supportsImport: PluginManager.shared.supportsImport( + for: AppState.shared.currentDatabaseType ?? .mysql + ) + ) { Button("Import...") { coordinator?.openImportDialog() } diff --git a/TableProTests/Views/SidebarContextMenuLogicTests.swift b/TableProTests/Views/SidebarContextMenuLogicTests.swift index 900fad36..438082d0 100644 --- a/TableProTests/Views/SidebarContextMenuLogicTests.swift +++ b/TableProTests/Views/SidebarContextMenuLogicTests.swift @@ -60,19 +60,19 @@ struct SidebarContextMenuLogicTests { // MARK: - Import Visibility - @Test("Import visible for table with SQL editor") + @Test("Import visible for table with import support") func importVisibleForTable() { - #expect(SidebarContextMenuLogic.importVisible(isView: false, editorLanguage: .sql)) + #expect(SidebarContextMenuLogic.importVisible(isView: false, supportsImport: true)) } @Test("Import hidden for view") func importHiddenForView() { - #expect(!SidebarContextMenuLogic.importVisible(isView: true, editorLanguage: .sql)) + #expect(!SidebarContextMenuLogic.importVisible(isView: true, supportsImport: true)) } - @Test("Import hidden for non-SQL editor") - func importHiddenForNonSQL() { - #expect(!SidebarContextMenuLogic.importVisible(isView: false, editorLanguage: .javascript)) + @Test("Import hidden when import not supported") + func importHiddenWhenNotSupported() { + #expect(!SidebarContextMenuLogic.importVisible(isView: false, supportsImport: false)) } // MARK: - Truncate Visibility