diff --git a/CHANGELOG.md b/CHANGELOG.md index 7791aadc0..4cf3af795 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `SettablePlugin` protocol in TableProPluginKit SDK: unified settings pattern for all plugins with automatic persistence via `loadSettings()`/`saveSettings()`, replacing duplicated boilerplate across export/import/driver plugins - Plugin UI/capability metadata: each driver plugin now self-declares brand color, connection mode, supported features, column types, URL schemes, and grouping strategy via the `DriverPlugin` protocol - Driver plugin settings view support: `DriverPlugin.settingsView()` allows plugins to provide custom settings UI in the Installed Plugins panel -- Dynamic connection fields: connection form Advanced tab now renders fields from `DriverPlugin.additionalConnectionFields` instead of hardcoded per-database sections, with support for text, secure, and dropdown field types +- Dynamic connection fields: connection form Advanced tab now renders fields from `DriverPlugin.additionalConnectionFields` instead of hardcoded per-database sections, with support for text, secure, dropdown, number, toggle, and stepper field types - Configurable plugin registry URL via `defaults write com.TablePro com.TablePro.customRegistryURL ` for enterprise/private registries - SQL import options (wrap in transaction, disable FK checks) now persist across launches - `needsRestart` banner persists across app quit/relaunch after plugin uninstall diff --git a/Plugins/RedisDriverPlugin/RedisPlugin.swift b/Plugins/RedisDriverPlugin/RedisPlugin.swift index 1f83993c5..34c217abd 100644 --- a/Plugins/RedisDriverPlugin/RedisPlugin.swift +++ b/Plugins/RedisDriverPlugin/RedisPlugin.swift @@ -21,7 +21,14 @@ final class RedisPlugin: NSObject, TableProPlugin, DriverPlugin { static let databaseDisplayName = "Redis" static let iconName = "cylinder.fill" static let defaultPort = 6379 - static let additionalConnectionFields: [ConnectionField] = [] + static let additionalConnectionFields: [ConnectionField] = [ + ConnectionField( + id: "redisDatabase", + label: String(localized: "Database Index"), + defaultValue: "0", + fieldType: .stepper(range: ConnectionField.IntRange(0...15)) + ), + ] static let additionalDatabaseTypeIds: [String] = [] // MARK: - UI/Capability Metadata diff --git a/Plugins/TableProPluginKit/ConnectionField.swift b/Plugins/TableProPluginKit/ConnectionField.swift index b8d382469..ac57fba42 100644 --- a/Plugins/TableProPluginKit/ConnectionField.swift +++ b/Plugins/TableProPluginKit/ConnectionField.swift @@ -1,10 +1,57 @@ import Foundation public struct ConnectionField: Codable, Sendable { + public struct IntRange: Codable, Sendable, Equatable { + public let lowerBound: Int + public let upperBound: Int + + public init(_ range: ClosedRange) { + self.lowerBound = range.lowerBound + self.upperBound = range.upperBound + } + + public init(lowerBound: Int, upperBound: Int) { + precondition(lowerBound <= upperBound, "IntRange: lowerBound must be <= upperBound") + self.lowerBound = lowerBound + self.upperBound = upperBound + } + + public var closedRange: ClosedRange { lowerBound...upperBound } + + private enum CodingKeys: String, CodingKey { + case lowerBound, upperBound + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let lower = try container.decode(Int.self, forKey: .lowerBound) + let upper = try container.decode(Int.self, forKey: .upperBound) + guard lower <= upper else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: container.codingPath, + debugDescription: "IntRange lowerBound (\(lower)) must be <= upperBound (\(upper))" + ) + ) + } + self.lowerBound = lower + self.upperBound = upper + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(lowerBound, forKey: .lowerBound) + try container.encode(upperBound, forKey: .upperBound) + } + } + public enum FieldType: Codable, Sendable, Equatable { case text case secure case dropdown(options: [DropdownOption]) + case number + case toggle + case stepper(range: IntRange) } public struct DropdownOption: Codable, Sendable, Equatable { diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index fd1e45f23..90f693a83 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -359,8 +359,6 @@ enum DatabaseDriverFactory { switch connection.type { case .mongodb: fields["sslCACertPath"] = ssl.caCertificatePath - case .redis: - fields["redisDatabase"] = String(connection.redisDatabase ?? 0) default: break } diff --git a/TablePro/Views/Connection/ConnectionFieldRow.swift b/TablePro/Views/Connection/ConnectionFieldRow.swift index a867734bb..17b569568 100644 --- a/TablePro/Views/Connection/ConnectionFieldRow.swift +++ b/TablePro/Views/Connection/ConnectionFieldRow.swift @@ -30,6 +30,37 @@ struct ConnectionFieldRow: View { Text(option.label).tag(option.value) } } + case .number: + TextField( + field.label, + text: Binding( + get: { value }, + set: { newValue in + value = String(newValue.unicodeScalars.filter { + CharacterSet.decimalDigits.contains($0) || $0 == "-" || $0 == "." + }) + } + ), + prompt: field.placeholder.isEmpty ? nil : Text(field.placeholder) + ) + case .toggle: + Toggle( + field.label, + isOn: Binding( + get: { value == "true" }, + set: { value = $0 ? "true" : "false" } + ) + ) + case .stepper(let range): + Stepper( + value: Binding( + get: { Int(value) ?? range.lowerBound }, + set: { value = String($0) } + ), + in: range.closedRange + ) { + Text("\(field.label): \(Int(value) ?? range.lowerBound)") + } } } } diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index fcdc31a08..b6abb4b49 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -650,20 +650,6 @@ struct ConnectionFormView: View { } } - if type == .redis { - Section("Redis") { - Stepper( - value: Binding( - get: { Int(database) ?? 0 }, - set: { database = String($0) } - ), - in: 0...15 - ) { - Text(String(localized: "Database Index: \(Int(database) ?? 0)")) - } - } - } - Section(String(localized: "Startup Commands")) { StartupCommandsEditor(text: $startupCommands) .frame(height: 80) @@ -877,17 +863,20 @@ struct ConnectionFormView: View { // Load additional fields from connection additionalFieldValues = existing.additionalFields + + // Migrate legacy Redis database index before default seeding + if existing.type == .redis, + additionalFieldValues["redisDatabase"] == nil, + let rdb = existing.redisDatabase { + additionalFieldValues["redisDatabase"] = String(rdb) + } + for field in PluginManager.shared.additionalConnectionFields(for: existing.type) { if additionalFieldValues[field.id] == nil, let defaultValue = field.defaultValue { additionalFieldValues[field.id] = defaultValue } } - // Load Redis settings (special case) - if existing.type == .redis, let rdb = existing.redisDatabase { - database = String(rdb) - } - // Load startup commands startupCommands = existing.startupCommands ?? "" usePgpass = existing.usePgpass @@ -965,7 +954,9 @@ struct ConnectionFormView: View { groupId: selectedGroupId, safeModeLevel: safeModeLevel, aiPolicy: aiPolicy, - redisDatabase: type == .redis ? (Int(database) ?? 0) : nil, + redisDatabase: type == .redis + ? Int(additionalFieldValues["redisDatabase"] ?? "0") + : nil, startupCommands: startupCommands.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : startupCommands, additionalFields: finalAdditionalFields.isEmpty ? nil : finalAdditionalFields @@ -1108,7 +1099,9 @@ struct ConnectionFormView: View { color: connectionColor, tagId: selectedTagId, groupId: selectedGroupId, - redisDatabase: type == .redis ? (Int(database) ?? 0) : nil, + redisDatabase: type == .redis + ? Int(additionalFieldValues["redisDatabase"] ?? "0") + : nil, startupCommands: startupCommands.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : startupCommands, additionalFields: finalAdditionalFields.isEmpty ? nil : finalAdditionalFields @@ -1248,6 +1241,9 @@ struct ConnectionFormView: View { if let authSourceValue = parsed.authSource, !authSourceValue.isEmpty { additionalFieldValues["mongoAuthSource"] = authSourceValue } + if parsed.type == .redis, !parsed.database.isEmpty { + additionalFieldValues["redisDatabase"] = parsed.database + } if let connectionName = parsed.connectionName, !connectionName.isEmpty { name = connectionName } else if name.isEmpty { diff --git a/TableProTests/Core/Plugins/ConnectionFieldTests.swift b/TableProTests/Core/Plugins/ConnectionFieldTests.swift index 642c08368..03f23fb1b 100644 --- a/TableProTests/Core/Plugins/ConnectionFieldTests.swift +++ b/TableProTests/Core/Plugins/ConnectionFieldTests.swift @@ -153,4 +153,116 @@ struct ConnectionFieldTests { #expect(decoded.id == field.id) #expect(decoded.fieldType == .text) } + + // MARK: - IntRange + + @Test("IntRange init from ClosedRange") + func intRangeFromClosedRange() { + let range = ConnectionField.IntRange(0...15) + #expect(range.lowerBound == 0) + #expect(range.upperBound == 15) + } + + @Test("IntRange closedRange round-trip") + func intRangeClosedRangeRoundTrip() { + let range = ConnectionField.IntRange(3...42) + #expect(range.closedRange == 3...42) + } + + @Test("IntRange init from bounds") + func intRangeFromBounds() { + let range = ConnectionField.IntRange(lowerBound: 1, upperBound: 100) + #expect(range.lowerBound == 1) + #expect(range.upperBound == 100) + #expect(range.closedRange == 1...100) + } + + @Test("IntRange decoding rejects invalid bounds") + func intRangeDecodingRejectsInvalidBounds() throws { + let json = #"{"lowerBound":10,"upperBound":0}"# + let data = Data(json.utf8) + #expect(throws: DecodingError.self) { + try JSONDecoder().decode(ConnectionField.IntRange.self, from: data) + } + } + + // MARK: - isSecure for new types + + @Test("isSecure is false for .number") + func isSecureForNumber() { + let field = ConnectionField(id: "port", label: "Port", fieldType: .number) + #expect(field.isSecure == false) + } + + @Test("isSecure is false for .toggle") + func isSecureForToggle() { + let field = ConnectionField(id: "flag", label: "Flag", fieldType: .toggle) + #expect(field.isSecure == false) + } + + @Test("isSecure is false for .stepper") + func isSecureForStepper() { + let range = ConnectionField.IntRange(0...15) + let field = ConnectionField(id: "db", label: "DB", fieldType: .stepper(range: range)) + #expect(field.isSecure == false) + } + + // MARK: - Codable round-trips for new types + + @Test("Codable round-trip for .number field") + func codableNumber() throws { + let field = ConnectionField( + id: "port", + label: "Port", + placeholder: "3306", + defaultValue: "3306", + fieldType: .number + ) + + let data = try JSONEncoder().encode(field) + let decoded = try JSONDecoder().decode(ConnectionField.self, from: data) + + #expect(decoded.id == field.id) + #expect(decoded.label == field.label) + #expect(decoded.placeholder == field.placeholder) + #expect(decoded.defaultValue == field.defaultValue) + #expect(decoded.fieldType == .number) + } + + @Test("Codable round-trip for .toggle field") + func codableToggle() throws { + let field = ConnectionField( + id: "compress", + label: "Compress", + defaultValue: "false", + fieldType: .toggle + ) + + let data = try JSONEncoder().encode(field) + let decoded = try JSONDecoder().decode(ConnectionField.self, from: data) + + #expect(decoded.id == field.id) + #expect(decoded.label == field.label) + #expect(decoded.defaultValue == "false") + #expect(decoded.fieldType == .toggle) + } + + @Test("Codable round-trip for .stepper field with IntRange") + func codableStepper() throws { + let range = ConnectionField.IntRange(0...15) + let field = ConnectionField( + id: "redisDatabase", + label: "Database Index", + defaultValue: "0", + fieldType: .stepper(range: range) + ) + + let data = try JSONEncoder().encode(field) + let decoded = try JSONDecoder().decode(ConnectionField.self, from: data) + + #expect(decoded.id == field.id) + #expect(decoded.label == field.label) + #expect(decoded.defaultValue == "0") + #expect(decoded.fieldType == .stepper(range: range)) + } }