From 7a47c5205c0fd2b4ffe5bd00ff2d5579cc746653 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 13 Mar 2026 14:22:46 +0700 Subject: [PATCH 1/4] feat: make ConnectionFormView fully dynamic via plugin metadata (#309) --- CHANGELOG.md | 1 + .../PostgreSQLPlugin.swift | 11 ++- Plugins/SQLiteDriverPlugin/SQLitePlugin.swift | 2 + .../TableProPluginKit/ConnectionField.swift | 13 ++- Plugins/TableProPluginKit/DriverPlugin.swift | 4 + TablePro/Core/Database/DatabaseDriver.swift | 4 +- TablePro/Core/Plugins/PluginManager.swift | 10 +++ .../Views/Connection/ConnectionFormView.swift | 79 +++++++++++-------- 8 files changed, 87 insertions(+), 37 deletions(-) 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..bd45879eb 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,7 @@ public struct ConnectionField: Codable, Sendable { self.isRequired = required self.defaultValue = defaultValue self.fieldType = fieldType ?? (secure ? .secure : .text) + self.section = section + self.hidesPassword = 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..cbe85e279 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -32,6 +32,22 @@ 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" } + } + + private func toggleBinding(for fieldId: String) -> Binding { + Binding( + get: { additionalFieldValues[fieldId] == "true" }, + set: { additionalFieldValues[fieldId] = $0 ? "true" : "false" } + ) + } + @State private var name: String = "" @State private var host: String = "" @State private var port: String = "" @@ -83,9 +99,19 @@ 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" + } + + private var usePgpassBinding: Binding { + Binding( + get: { additionalFieldValues["usePgpass"] == "true" }, + set: { additionalFieldValues["usePgpass"] = $0 ? "true" : "false" } + ) + } + // Pre-connect script @State private var preConnectScript: String = "" @@ -148,9 +174,6 @@ struct ConnectionFormView: View { 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 +183,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 +193,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 +301,18 @@ struct ConnectionFormView: View { prompt: Text("root") ) } - if type.pluginTypeId == "PostgreSQL" { - Toggle(String(localized: "Use ~/.pgpass"), isOn: $usePgpass) + ForEach(authSectionFields, id: \.id) { field in + if field.fieldType == .toggle { + Toggle(field.label, isOn: toggleBinding(for: field.id)) + } } - if !usePgpass || type.pluginTypeId != "PostgreSQL" { + if !hidePasswordField { SecureField( String(localized: "Password"), text: $password ) } - if usePgpass && type.pluginTypeId == "PostgreSQL" { + if additionalFieldValues["usePgpass"] == "true" { pgpassStatusView } } @@ -775,7 +805,7 @@ struct ConnectionFormView: View { } private func updatePgpassStatus() { - guard usePgpass, type.pluginTypeId == "PostgreSQL" else { + guard additionalFieldValues["usePgpass"] == "true" else { pgpassStatus = .notChecked return } @@ -827,8 +857,8 @@ struct ConnectionFormView: View { 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 +871,6 @@ struct ConnectionFormView: View { // Load startup commands startupCommands = existing.startupCommands ?? "" - usePgpass = existing.usePgpass preConnectScript = existing.preConnectScript ?? "" // Load passwords from Keychain @@ -888,11 +917,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 +939,7 @@ struct ConnectionFormView: View { groupId: selectedGroupId, safeModeLevel: safeModeLevel, aiPolicy: aiPolicy, - redisDatabase: type.pluginTypeId == "Redis" - ? Int(additionalFieldValues["redisDatabase"] ?? "0") - : nil, + redisDatabase: Int(additionalFieldValues["redisDatabase"] ?? ""), startupCommands: startupCommands.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : startupCommands, additionalFields: finalAdditionalFields.isEmpty ? nil : finalAdditionalFields @@ -1047,11 +1069,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 +1088,7 @@ struct ConnectionFormView: View { color: connectionColor, tagId: selectedTagId, groupId: selectedGroupId, - redisDatabase: type.pluginTypeId == "Redis" - ? Int(additionalFieldValues["redisDatabase"] ?? "0") - : nil, + redisDatabase: Int(additionalFieldValues["redisDatabase"] ?? ""), startupCommands: startupCommands.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : startupCommands, additionalFields: finalAdditionalFields.isEmpty ? nil : finalAdditionalFields From 3179167501bd8a6f5867ad2502636db36b5e7f49 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 13 Mar 2026 14:29:41 +0700 Subject: [PATCH 2/4] fix: remove dead code, restore Redis default, add Codable backward compat --- Plugins/TableProPluginKit/ConnectionField.swift | 16 ++++++++++++++++ .../Views/Connection/ConnectionFormView.swift | 11 ++--------- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/Plugins/TableProPluginKit/ConnectionField.swift b/Plugins/TableProPluginKit/ConnectionField.swift index bd45879eb..0a5163e25 100644 --- a/Plugins/TableProPluginKit/ConnectionField.swift +++ b/Plugins/TableProPluginKit/ConnectionField.swift @@ -104,4 +104,20 @@ public struct ConnectionField: Codable, Sendable { 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/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index cbe85e279..2dd68ad9c 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -105,13 +105,6 @@ struct ConnectionFormView: View { additionalFieldValues["usePgpass"] == "true" } - private var usePgpassBinding: Binding { - Binding( - get: { additionalFieldValues["usePgpass"] == "true" }, - set: { additionalFieldValues["usePgpass"] = $0 ? "true" : "false" } - ) - } - // Pre-connect script @State private var preConnectScript: String = "" @@ -939,7 +932,7 @@ struct ConnectionFormView: View { groupId: selectedGroupId, safeModeLevel: safeModeLevel, aiPolicy: aiPolicy, - redisDatabase: Int(additionalFieldValues["redisDatabase"] ?? ""), + redisDatabase: Int(additionalFieldValues["redisDatabase"] ?? "0"), startupCommands: startupCommands.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : startupCommands, additionalFields: finalAdditionalFields.isEmpty ? nil : finalAdditionalFields @@ -1088,7 +1081,7 @@ struct ConnectionFormView: View { color: connectionColor, tagId: selectedTagId, groupId: selectedGroupId, - redisDatabase: Int(additionalFieldValues["redisDatabase"] ?? ""), + redisDatabase: Int(additionalFieldValues["redisDatabase"] ?? "0"), startupCommands: startupCommands.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : startupCommands, additionalFields: finalAdditionalFields.isEmpty ? nil : finalAdditionalFields From fe262a872b9427c391b4d44b4c5ce6fcaba651a5 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 13 Mar 2026 15:45:02 +0700 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94=20?= =?UTF-8?q?filter=20Advanced=20tab,=20normalize=20selected=20tab,=20harden?= =?UTF-8?q?=20redis=20parsing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TablePro/Views/Connection/ConnectionFormView.swift | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index 2dd68ad9c..c44d8c0ab 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -162,8 +162,7 @@ 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 = [:] @@ -651,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( @@ -849,7 +849,6 @@ struct ConnectionFormView: View { // Load additional fields from connection additionalFieldValues = existing.additionalFields - // Migrate legacy Redis database index before default seeding // Migrate legacy redisDatabase to additionalFields if additionalFieldValues["redisDatabase"] == nil, let rdb = existing.redisDatabase { @@ -932,7 +931,7 @@ struct ConnectionFormView: View { groupId: selectedGroupId, safeModeLevel: safeModeLevel, aiPolicy: aiPolicy, - redisDatabase: Int(additionalFieldValues["redisDatabase"] ?? "0"), + redisDatabase: Int(additionalFieldValues["redisDatabase"] ?? "") ?? 0, startupCommands: startupCommands.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : startupCommands, additionalFields: finalAdditionalFields.isEmpty ? nil : finalAdditionalFields @@ -1081,7 +1080,7 @@ struct ConnectionFormView: View { color: connectionColor, tagId: selectedTagId, groupId: selectedGroupId, - redisDatabase: Int(additionalFieldValues["redisDatabase"] ?? "0"), + redisDatabase: Int(additionalFieldValues["redisDatabase"] ?? "") ?? 0, startupCommands: startupCommands.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : startupCommands, additionalFields: finalAdditionalFields.isEmpty ? nil : finalAdditionalFields From b5fe3adb94bb64f243d69c13066b2c512c15e056 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 13 Mar 2026 15:54:57 +0700 Subject: [PATCH 4/4] fix: use ConnectionFieldRow for auth fields, preserve nil redisDatabase for non-Redis --- .../Views/Connection/ConnectionFormView.swift | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index c44d8c0ab..8fdeda5da 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -41,13 +41,6 @@ struct ConnectionFormView: View { authSectionFields.contains { $0.hidesPassword && additionalFieldValues[$0.id] == "true" } } - private func toggleBinding(for fieldId: String) -> Binding { - Binding( - get: { additionalFieldValues[fieldId] == "true" }, - set: { additionalFieldValues[fieldId] = $0 ? "true" : "false" } - ) - } - @State private var name: String = "" @State private var host: String = "" @State private var port: String = "" @@ -294,9 +287,16 @@ struct ConnectionFormView: View { ) } ForEach(authSectionFields, id: \.id) { field in - if field.fieldType == .toggle { - Toggle(field.label, isOn: toggleBinding(for: field.id)) - } + ConnectionFieldRow( + field: field, + value: Binding( + get: { + additionalFieldValues[field.id] + ?? field.defaultValue ?? "" + }, + set: { additionalFieldValues[field.id] = $0 } + ) + ) } if !hidePasswordField { SecureField( @@ -931,7 +931,7 @@ struct ConnectionFormView: View { groupId: selectedGroupId, safeModeLevel: safeModeLevel, aiPolicy: aiPolicy, - redisDatabase: Int(additionalFieldValues["redisDatabase"] ?? "") ?? 0, + redisDatabase: additionalFieldValues["redisDatabase"].map { Int($0) ?? 0 }, startupCommands: startupCommands.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : startupCommands, additionalFields: finalAdditionalFields.isEmpty ? nil : finalAdditionalFields @@ -1080,7 +1080,7 @@ struct ConnectionFormView: View { color: connectionColor, tagId: selectedTagId, groupId: selectedGroupId, - redisDatabase: Int(additionalFieldValues["redisDatabase"] ?? "") ?? 0, + redisDatabase: additionalFieldValues["redisDatabase"].map { Int($0) ?? 0 }, startupCommands: startupCommands.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : startupCommands, additionalFields: finalAdditionalFields.isEmpty ? nil : finalAdditionalFields