Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
2 changes: 2 additions & 0 deletions Plugins/MongoDBDriverPlugin/MongoDBPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
1 change: 1 addition & 0 deletions Plugins/OracleDriverPlugin/OraclePlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
2 changes: 2 additions & 0 deletions Plugins/RedisDriverPlugin/RedisPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]] = [
Expand Down
6 changes: 6 additions & 0 deletions Plugins/TableProPluginKit/DriverPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 }
}
47 changes: 47 additions & 0 deletions TablePro/AppDelegate+ConnectionHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ enum QueuedURLEntry {
case databaseURL(URL)
case sqliteFile(URL)
case duckdbFile(URL)
case genericDatabaseFile(URL, DatabaseType)
}

extension AppDelegate {
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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()
Expand Down
48 changes: 19 additions & 29 deletions TablePro/AppDelegate+FileOpen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,32 +14,21 @@ private let fileOpenLogger = Logger(subsystem: "com.TablePro", category: "FileOp
extension AppDelegate {
// MARK: - URL Classification

private static let databaseURLSchemes: Set<String> = [
"postgresql", "postgres", "mysql", "mariadb", "sqlite",
"mongodb", "mongodb+srv", "redis", "rediss", "redshift",
"mssql", "sqlserver", "oracle", "duckdb"
]

static let sqliteFileExtensions: Set<String> = [
"sqlite", "sqlite3", "db3", "s3db", "sl3", "sqlitedb"
]

static let duckdbFileExtensions: Set<String> = ["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
Expand Down Expand Up @@ -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:
self.handleGenericDatabaseFile(url, type: dbType)
}
}
self.scheduleWelcomeWindowSuppression()
}
}
Expand Down
36 changes: 21 additions & 15 deletions TablePro/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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) Query"
} else {
defaultTitle = "SQL Query"
}
Expand Down Expand Up @@ -94,8 +92,10 @@ 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.currentDatabaseType = session.connection.type
AppState.shared.supportsDatabaseSwitching = PluginManager.shared.supportsDatabaseSwitching(
for: session.connection.type)
}
} else {
currentSession = nil
Expand All @@ -119,8 +119,9 @@ 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.currentDatabaseType = nil
AppState.shared.supportsDatabaseSwitching = true

// Close all native tab windows for this connection and
// force AppKit to deallocate them instead of pooling.
Expand Down Expand Up @@ -150,8 +151,10 @@ 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.currentDatabaseType = 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
Expand All @@ -178,13 +181,16 @@ 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.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.isMongoDB = false
AppState.shared.isRedis = false
AppState.shared.editorLanguage = .sql
AppState.shared.currentDatabaseType = nil
AppState.shared.supportsDatabaseSwitching = true
}
}
}
Expand Down
19 changes: 7 additions & 12 deletions TablePro/Core/AI/AIPromptTemplates.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading
Loading