diff --git a/CHANGELOG.md b/CHANGELOG.md index bb59d0e47..e8e5c2d37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ 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 +- ConnectionFormView now fully dynamic: pgpass toggle, password visibility, and SSH/SSL tab visibility all driven by plugin metadata (`FieldSection`, `hidesPassword`, `supportsSSH`/`supportsSSL`) instead of hardcoded type checks - 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 diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift index cffc41809..d77935ff2 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift @@ -21,7 +21,16 @@ final class PostgreSQLPlugin: NSObject, TableProPlugin, DriverPlugin { static let databaseDisplayName = "PostgreSQL" static let iconName = "cylinder.fill" static let defaultPort = 5432 - static let additionalConnectionFields: [ConnectionField] = [] + static let additionalConnectionFields: [ConnectionField] = [ + ConnectionField( + id: "usePgpass", + label: String(localized: "Use ~/.pgpass"), + defaultValue: "false", + fieldType: .toggle, + section: .authentication, + hidesPassword: true + ) + ] static let additionalDatabaseTypeIds: [String] = ["Redshift"] // MARK: - UI/Capability Metadata diff --git a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift index d578438c6..9b07b1e58 100644 --- a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift +++ b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift @@ -22,6 +22,8 @@ final class SQLitePlugin: NSObject, TableProPlugin, DriverPlugin { // MARK: - UI/Capability Metadata static let requiresAuthentication = false + static let supportsSSH = false + static let supportsSSL = false static let connectionMode: ConnectionMode = .fileBased static let urlSchemes: [String] = ["sqlite"] static let fileExtensions: [String] = ["db", "sqlite", "sqlite3"] diff --git a/Plugins/TableProPluginKit/ConnectionField.swift b/Plugins/TableProPluginKit/ConnectionField.swift index ac57fba42..0a5163e25 100644 --- a/Plugins/TableProPluginKit/ConnectionField.swift +++ b/Plugins/TableProPluginKit/ConnectionField.swift @@ -1,5 +1,10 @@ import Foundation +public enum FieldSection: String, Codable, Sendable { + case authentication + case advanced +} + public struct ConnectionField: Codable, Sendable { public struct IntRange: Codable, Sendable, Equatable { public let lowerBound: Int @@ -70,6 +75,8 @@ public struct ConnectionField: Codable, Sendable { public let isRequired: Bool public let defaultValue: String? public let fieldType: FieldType + public let section: FieldSection + public let hidesPassword: Bool /// Backward-compatible convenience: true when fieldType is .secure public var isSecure: Bool { @@ -84,7 +91,9 @@ public struct ConnectionField: Codable, Sendable { required: Bool = false, secure: Bool = false, defaultValue: String? = nil, - fieldType: FieldType? = nil + fieldType: FieldType? = nil, + section: FieldSection = .advanced, + hidesPassword: Bool = false ) { self.id = id self.label = label @@ -92,5 +101,23 @@ public struct ConnectionField: Codable, Sendable { self.isRequired = required self.defaultValue = defaultValue self.fieldType = fieldType ?? (secure ? .secure : .text) + self.section = section + self.hidesPassword = hidesPassword + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + label = try container.decode(String.self, forKey: .label) + placeholder = try container.decodeIfPresent(String.self, forKey: .placeholder) ?? "" + isRequired = try container.decodeIfPresent(Bool.self, forKey: .isRequired) ?? false + defaultValue = try container.decodeIfPresent(String.self, forKey: .defaultValue) + fieldType = try container.decode(FieldType.self, forKey: .fieldType) + section = try container.decodeIfPresent(FieldSection.self, forKey: .section) ?? .advanced + hidesPassword = try container.decodeIfPresent(Bool.self, forKey: .hidesPassword) ?? false + } + + private enum CodingKeys: String, CodingKey { + case id, label, placeholder, isRequired, defaultValue, fieldType, section, hidesPassword } } diff --git a/Plugins/TableProPluginKit/DriverPlugin.swift b/Plugins/TableProPluginKit/DriverPlugin.swift index be458253e..fffc06883 100644 --- a/Plugins/TableProPluginKit/DriverPlugin.swift +++ b/Plugins/TableProPluginKit/DriverPlugin.swift @@ -46,6 +46,8 @@ public protocol DriverPlugin: TableProPlugin { static var structureColumnFields: [StructureColumnField] { get } static var defaultPrimaryKeyColumn: String? { get } static var supportsQueryProgress: Bool { get } + static var supportsSSH: Bool { get } + static var supportsSSL: Bool { get } } public extension DriverPlugin { @@ -98,4 +100,6 @@ public extension DriverPlugin { } static var defaultPrimaryKeyColumn: String? { nil } static var supportsQueryProgress: Bool { false } + static var supportsSSH: Bool { true } + static var supportsSSL: Bool { true } } diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index 6506ed528..41290fbf7 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -352,9 +352,7 @@ enum DatabaseDriverFactory { } private static func resolvePassword(for connection: DatabaseConnection) -> String { - if connection.usePgpass - && (connection.type == .postgresql || connection.type == .redshift) - { + if connection.usePgpass { return "" } return ConnectionStorage.shared.loadPassword(for: connection.id) ?? "" diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index af2650503..2c789df95 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -494,6 +494,16 @@ final class PluginManager { return Swift.type(of: plugin).supportsQueryProgress } + func supportsSSH(for databaseType: DatabaseType) -> Bool { + guard let plugin = driverPlugin(for: databaseType) else { return true } + return Swift.type(of: plugin).supportsSSH + } + + func supportsSSL(for databaseType: DatabaseType) -> Bool { + guard let plugin = driverPlugin(for: databaseType) else { return true } + return Swift.type(of: plugin).supportsSSL + } + func autoLimitStyle(for databaseType: DatabaseType) -> AutoLimitStyle { guard let plugin = driverPlugin(for: databaseType) else { return .limit } guard let dialect = Swift.type(of: plugin).sqlDialect else { return .none } diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index e1ef5610e..8fdeda5da 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -32,6 +32,15 @@ struct ConnectionFormView: View { PluginManager.shared.additionalConnectionFields(for: type) } + private var authSectionFields: [ConnectionField] { + PluginManager.shared.additionalConnectionFields(for: type) + .filter { $0.section == .authentication } + } + + private var hidePasswordField: Bool { + authSectionFields.contains { $0.hidesPassword && additionalFieldValues[$0.id] == "true" } + } + @State private var name: String = "" @State private var host: String = "" @State private var port: String = "" @@ -83,9 +92,12 @@ struct ConnectionFormView: View { @State private var startupCommands: String = "" // Pgpass - @State private var usePgpass: Bool = false @State private var pgpassStatus: PgpassStatus = .notChecked + private var usePgpass: Bool { + additionalFieldValues["usePgpass"] == "true" + } + // Pre-connect script @State private var preConnectScript: String = "" @@ -143,14 +155,10 @@ struct ConnectionFormView: View { if hasLoadedData { port = String(newType.defaultPort) } - let isFileBased = PluginManager.shared.connectionMode(for: newType) == .fileBased - if isFileBased && (selectedTab == .ssh || selectedTab == .ssl) { + if !visibleTabs.contains(selectedTab) { selectedTab = .general } additionalFieldValues = [:] - if newType.pluginTypeId != "PostgreSQL" { - usePgpass = false - } for field in PluginManager.shared.additionalConnectionFields(for: newType) { if let defaultValue = field.defaultValue { additionalFieldValues[field.id] = defaultValue @@ -160,7 +168,7 @@ struct ConnectionFormView: View { .pluginInstallPrompt(connection: $pluginInstallConnection) { connection in connectAfterInstall(connection) } - .onChange(of: usePgpass) { _, _ in updatePgpassStatus() } + .onChange(of: additionalFieldValues) { _, _ in updatePgpassStatus() } .onChange(of: host) { _, _ in updatePgpassStatus() } .onChange(of: port) { _, _ in updatePgpassStatus() } .onChange(of: database) { _, _ in updatePgpassStatus() } @@ -170,10 +178,15 @@ struct ConnectionFormView: View { // MARK: - Tab Picker Helpers private var visibleTabs: [FormTab] { - if PluginManager.shared.connectionMode(for: type) == .fileBased { - return [.general, .advanced] + var tabs: [FormTab] = [.general] + if PluginManager.shared.supportsSSH(for: type) { + tabs.append(.ssh) + } + if PluginManager.shared.supportsSSL(for: type) { + tabs.append(.ssl) } - return FormTab.allCases + tabs.append(.advanced) + return tabs } private var resolvedSSHAgentSocketPath: String { @@ -273,16 +286,25 @@ struct ConnectionFormView: View { prompt: Text("root") ) } - if type.pluginTypeId == "PostgreSQL" { - Toggle(String(localized: "Use ~/.pgpass"), isOn: $usePgpass) + ForEach(authSectionFields, id: \.id) { field in + ConnectionFieldRow( + field: field, + value: Binding( + get: { + additionalFieldValues[field.id] + ?? field.defaultValue ?? "" + }, + set: { additionalFieldValues[field.id] = $0 } + ) + ) } - if !usePgpass || type.pluginTypeId != "PostgreSQL" { + if !hidePasswordField { SecureField( String(localized: "Password"), text: $password ) } - if usePgpass && type.pluginTypeId == "PostgreSQL" { + if additionalFieldValues["usePgpass"] == "true" { pgpassStatusView } } @@ -628,9 +650,10 @@ struct ConnectionFormView: View { private var advancedForm: some View { Form { - if !additionalConnectionFields.isEmpty { + let advancedFields = additionalConnectionFields.filter { $0.section == .advanced } + if !advancedFields.isEmpty { Section(type.displayName) { - ForEach(additionalConnectionFields, id: \.id) { field in + ForEach(advancedFields, id: \.id) { field in ConnectionFieldRow( field: field, value: Binding( @@ -775,7 +798,7 @@ struct ConnectionFormView: View { } private func updatePgpassStatus() { - guard usePgpass, type.pluginTypeId == "PostgreSQL" else { + guard additionalFieldValues["usePgpass"] == "true" else { pgpassStatus = .notChecked return } @@ -826,9 +849,8 @@ struct ConnectionFormView: View { // Load additional fields from connection additionalFieldValues = existing.additionalFields - // Migrate legacy Redis database index before default seeding - if existing.type.pluginTypeId == "Redis", - additionalFieldValues["redisDatabase"] == nil, + // Migrate legacy redisDatabase to additionalFields + if additionalFieldValues["redisDatabase"] == nil, let rdb = existing.redisDatabase { additionalFieldValues["redisDatabase"] = String(rdb) } @@ -841,7 +863,6 @@ struct ConnectionFormView: View { // Load startup commands startupCommands = existing.startupCommands ?? "" - usePgpass = existing.usePgpass preConnectScript = existing.preConnectScript ?? "" // Load passwords from Keychain @@ -888,11 +909,6 @@ struct ConnectionFormView: View { ? "root" : trimmedUsername var finalAdditionalFields = additionalFieldValues - if usePgpass && type.pluginTypeId == "PostgreSQL" { - finalAdditionalFields["usePgpass"] = "true" - } else { - finalAdditionalFields.removeValue(forKey: "usePgpass") - } let trimmedScript = preConnectScript.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmedScript.isEmpty { finalAdditionalFields["preConnectScript"] = preConnectScript @@ -915,9 +931,7 @@ struct ConnectionFormView: View { groupId: selectedGroupId, safeModeLevel: safeModeLevel, aiPolicy: aiPolicy, - redisDatabase: type.pluginTypeId == "Redis" - ? Int(additionalFieldValues["redisDatabase"] ?? "0") - : nil, + redisDatabase: additionalFieldValues["redisDatabase"].map { Int($0) ?? 0 }, startupCommands: startupCommands.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : startupCommands, additionalFields: finalAdditionalFields.isEmpty ? nil : finalAdditionalFields @@ -1047,11 +1061,6 @@ struct ConnectionFormView: View { ? "root" : trimmedUsername var finalAdditionalFields = additionalFieldValues - if usePgpass && type.pluginTypeId == "PostgreSQL" { - finalAdditionalFields["usePgpass"] = "true" - } else { - finalAdditionalFields.removeValue(forKey: "usePgpass") - } let trimmedScript = preConnectScript.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmedScript.isEmpty { finalAdditionalFields["preConnectScript"] = preConnectScript @@ -1071,9 +1080,7 @@ struct ConnectionFormView: View { color: connectionColor, tagId: selectedTagId, groupId: selectedGroupId, - redisDatabase: type.pluginTypeId == "Redis" - ? Int(additionalFieldValues["redisDatabase"] ?? "0") - : nil, + redisDatabase: additionalFieldValues["redisDatabase"].map { Int($0) ?? 0 }, startupCommands: startupCommands.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : startupCommands, additionalFields: finalAdditionalFields.isEmpty ? nil : finalAdditionalFields