diff --git a/CHANGELOG.md b/CHANGELOG.md index 01b90fd99..b59ddd132 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- MongoDB multi-host connections for replica sets - JSON results view mode with Data/Structure/JSON toggle in status bar - Import URL: dynamic placeholder, parsed preview, clipboard auto-paste, libSQL/D1 support, URL schemes for Oracle/ClickHouse/etcd/D1/libSQL - In-app feedback form for bug reports and feature requests via Help > Report an Issue @@ -27,6 +28,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Connection form overflow with SSH jump hosts and TOTP fields +- Missing confirmation dialog on group deletion +- Plugin principalClass resolved off main thread - Crash when scrolling AI Chat during streaming on macOS 15.x - Connection failure on PostgreSQL-compatible databases (e.g., Aurora DSQL) that don't support `SET statement_timeout` - Schema-qualified table names (e.g. `public.users`) now correctly resolve in autocomplete diff --git a/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift b/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift index 3fcc490bd..ad74d85bc 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift @@ -177,14 +177,28 @@ final class MongoDBConnection: @unchecked Sendable { } } - let encodedHost = host.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? host - let encodedDb = database.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? database - if useSrv { + let encodedHost = host.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? host uri += encodedHost + } else if host.contains(",") { + let segments = host.split(separator: ",").compactMap { segment -> String? in + let parts = segment.split(separator: ":", maxSplits: 1) + guard let first = parts.first else { return nil } + let h = String(first).trimmingCharacters(in: .whitespaces) + guard !h.isEmpty else { return nil } + let encodedH = h.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? h + if parts.count > 1 { + return "\(encodedH):\(parts[1].trimmingCharacters(in: .whitespaces))" + } + return "\(encodedH):\(port)" + } + uri += segments.isEmpty ? "localhost:\(port)" : segments.joined(separator: ",") } else { + let encodedHost = host.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? host uri += "\(encodedHost):\(port)" } + + let encodedDb = database.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? database uri += database.isEmpty ? "/" : "/\(encodedDb)" let effectiveAuthSource: String diff --git a/Plugins/MongoDBDriverPlugin/MongoDBPlugin.swift b/Plugins/MongoDBDriverPlugin/MongoDBPlugin.swift index 076626597..7cb9e2c7d 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBPlugin.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBPlugin.swift @@ -17,6 +17,13 @@ final class MongoDBPlugin: NSObject, TableProPlugin, DriverPlugin { static let iconName = "mongodb-icon" static let defaultPort = 27017 static let additionalConnectionFields: [ConnectionField] = [ + ConnectionField( + id: "mongoHosts", + label: "Hosts", + placeholder: "localhost:27017", + fieldType: .hostList, + section: .connection + ), ConnectionField(id: "mongoAuthSource", label: "Auth Database", placeholder: "admin"), ConnectionField( id: "mongoReadPreference", diff --git a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift index 77faf8066..5a89ea722 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift @@ -51,8 +51,11 @@ final class MongoDBPluginDriver: PluginDatabaseDriver { } } + let effectiveHost = config.additionalFields["mongoHosts"].flatMap { hosts in + hosts.isEmpty ? nil : hosts + } ?? config.host let conn = MongoDBConnection( - host: config.host, + host: effectiveHost, port: config.port, user: config.username, password: config.password, diff --git a/Plugins/TableProPluginKit/ConnectionField.swift b/Plugins/TableProPluginKit/ConnectionField.swift index 2fcede2cf..80dcc56f2 100644 --- a/Plugins/TableProPluginKit/ConnectionField.swift +++ b/Plugins/TableProPluginKit/ConnectionField.swift @@ -3,6 +3,7 @@ import Foundation public enum FieldSection: String, Codable, Sendable { case authentication case advanced + case connection } public struct FieldVisibilityRule: Codable, Sendable, Equatable { @@ -67,6 +68,7 @@ public struct ConnectionField: Codable, Sendable { case number case toggle case stepper(range: IntRange) + case hostList } public struct DropdownOption: Codable, Sendable, Equatable { diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index d0cc42fb3..bfbb5bca1 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -176,26 +176,19 @@ final class PluginManager { } /// Holds the result of loading a single plugin bundle off the main thread. - /// Bundle is not formally Sendable but is thread-safe for property reads after load(). + /// Only stores the loaded Bundle and its origin. Protocol property reads + /// (principalClass, static vars) happen later on @MainActor in registerLoadedPlugins + /// to avoid ObjC runtime thread-safety issues during initial class resolution. private struct LoadedBundle: @unchecked Sendable { let url: URL let source: PluginSource let bundle: Bundle - let principalClassName: String - - // These are extracted off-main since they're static protocol properties - let pluginName: String - let pluginVersion: String - let pluginDescription: String - let capabilities: [PluginCapability] - let databaseTypeId: String? - let additionalTypeIds: [String] - let pluginIconName: String - let defaultPort: Int? } - /// Perform the expensive bundle.load() and principalClass resolution off MainActor. - /// Returns successfully loaded bundles with their metadata extracted. + /// Perform the expensive bundle.load() off MainActor (dynamic linker I/O). + /// Does NOT access bundle.principalClass — that triggers ObjC runtime dispatch + /// that is not thread-safe during initial resolution. Version checks use Info.plist + /// which is safe after load(). nonisolated private static func loadBundlesOffMain( _ pending: [(url: URL, source: PluginSource)] ) async -> [LoadedBundle] { @@ -234,62 +227,48 @@ final class PluginManager { continue } - guard let principalClass = bundle.principalClass as? any TableProPlugin.Type else { - logger.error("Principal class does not conform to TableProPlugin: \(entry.url.lastPathComponent)") - continue - } - - let driverType = principalClass as? any DriverPlugin.Type - let version = readRegistryVersion(for: entry.url) ?? principalClass.pluginVersion - let loaded = LoadedBundle( - url: entry.url, - source: entry.source, - bundle: bundle, - principalClassName: NSStringFromClass(principalClass), - pluginName: principalClass.pluginName, - pluginVersion: version, - pluginDescription: principalClass.pluginDescription, - capabilities: principalClass.capabilities, - databaseTypeId: driverType?.databaseTypeId, - additionalTypeIds: driverType?.additionalDatabaseTypeIds ?? [], - pluginIconName: driverType?.iconName ?? "puzzlepiece", - defaultPort: driverType?.defaultPort - ) - results.append(loaded) + results.append(LoadedBundle(url: entry.url, source: entry.source, bundle: bundle)) } return results } /// Register pre-loaded bundles into the plugin dictionaries. Must be called on MainActor. + /// Resolves principalClass and reads all static protocol properties here (safe on main thread) + /// rather than in loadBundlesOffMain where ObjC runtime dispatch is not thread-safe. private func registerLoadedPlugins(_ loaded: [LoadedBundle]) { let disabled = disabledPluginIds for item in loaded { + guard let principalClass = item.bundle.principalClass as? any TableProPlugin.Type else { + Self.logger.error("Principal class does not conform to TableProPlugin: \(item.url.lastPathComponent)") + continue + } + let bundleId = item.bundle.bundleIdentifier ?? item.url.lastPathComponent + let driverType = principalClass as? any DriverPlugin.Type + let version = Self.readRegistryVersion(for: item.url) ?? principalClass.pluginVersion let entry = PluginEntry( id: bundleId, bundle: item.bundle, url: item.url, source: item.source, - name: item.pluginName, - version: item.pluginVersion, - pluginDescription: item.pluginDescription, - capabilities: item.capabilities, + name: principalClass.pluginName, + version: version, + pluginDescription: principalClass.pluginDescription, + capabilities: principalClass.capabilities, isEnabled: !disabled.contains(bundleId), - databaseTypeId: item.databaseTypeId, - additionalTypeIds: item.additionalTypeIds, - pluginIconName: item.pluginIconName, - defaultPort: item.defaultPort + databaseTypeId: driverType?.databaseTypeId, + additionalTypeIds: driverType?.additionalDatabaseTypeIds ?? [], + pluginIconName: driverType?.iconName ?? "puzzlepiece", + defaultPort: driverType?.defaultPort ) plugins.append(entry) + validateCapabilityDeclarations(principalClass, pluginId: bundleId) - if let principalClass = item.bundle.principalClass as? any TableProPlugin.Type { - validateCapabilityDeclarations(principalClass, pluginId: bundleId) - if entry.isEnabled { - let instance = principalClass.init() - registerCapabilities(instance, pluginId: bundleId) - } + if entry.isEnabled { + let instance = principalClass.init() + registerCapabilities(instance, pluginId: bundleId) } Self.logger.info("Loaded plugin '\(entry.name)' v\(entry.version) [\(item.source == .builtIn ? "built-in" : "user")]") diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index fb5d6b67a..9c888d97f 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -549,6 +549,13 @@ extension PluginMetadataRegistry { ), connection: PluginMetadataSnapshot.ConnectionConfig( additionalConnectionFields: [ + ConnectionField( + id: "mongoHosts", + label: "Hosts", + placeholder: "localhost:27017", + fieldType: .hostList, + section: .connection + ), ConnectionField( id: "mongoAuthSource", label: "Auth Database", placeholder: "admin" ), diff --git a/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift b/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift index 975de2321..6d0bf63d0 100644 --- a/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift +++ b/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift @@ -35,18 +35,20 @@ struct ParsedConnectionURL { let oracleServiceName: String? let useSrv: Bool let mongoQueryParams: [String: String] + let multiHost: String? var suggestedName: String { if let connectionName, !connectionName.isEmpty { return connectionName } let typeName = type.rawValue + let displayHost = multiHost?.split(separator: ",").first.map(String.init) ?? host let displayDatabase = database.isEmpty ? (oracleServiceName ?? "") : database if !displayDatabase.isEmpty { - return "\(typeName) \(host)/\(displayDatabase)" + return "\(typeName) \(displayHost)/\(displayDatabase)" } - if !host.isEmpty { - return "\(typeName) \(host)" + if !displayHost.isEmpty { + return "\(typeName) \(displayHost)" } return typeName } @@ -162,7 +164,8 @@ struct ConnectionURLParser { filterCondition: nil, oracleServiceName: nil, useSrv: false, - mongoQueryParams: [:] + mongoQueryParams: [:], + multiHost: nil )) } @@ -170,6 +173,14 @@ struct ConnectionURLParser { return parseSSHURL(trimmed, schemeEnd: schemeEnd, dbType: dbType) } + // Multi-host MongoDB URI: URLComponents can't parse comma-separated hosts + if dbType == .mongodb && !isSrv { + let afterScheme = String(trimmed[schemeEnd.upperBound...]) + if let multiHostResult = parseMultiHostMongoDB(afterScheme, dbType: dbType, isSrv: isSrv) { + return .success(multiHostResult) + } + } + let httpURL = "http://" + String(trimmed[schemeEnd.upperBound...]) guard let components = URLComponents(string: httpURL) else { return .failure(.invalidURL) @@ -256,7 +267,8 @@ struct ConnectionURLParser { filterCondition: ext.filterCondition, oracleServiceName: oracleServiceName, useSrv: ext.useSrv, - mongoQueryParams: ext.mongoQueryParams + mongoQueryParams: ext.mongoQueryParams, + multiHost: nil )) } @@ -389,10 +401,107 @@ struct ConnectionURLParser { filterCondition: ext.filterCondition, oracleServiceName: oracleServiceName, useSrv: ext.useSrv, - mongoQueryParams: ext.mongoQueryParams + mongoQueryParams: ext.mongoQueryParams, + multiHost: nil )) } + // MARK: - Multi-Host MongoDB Parsing + + private static func parseMultiHostMongoDB( + _ afterScheme: String, + dbType: DatabaseType, + isSrv: Bool + ) -> ParsedConnectionURL? { + var mainPart = afterScheme + var queryString: String? + if let questionIndex = afterScheme.firstIndex(of: "?") { + mainPart = String(afterScheme[afterScheme.startIndex.. 1 ? Int(hostParts[1]) : nil + + var queryItems: [URLQueryItem]? + if let qs = queryString { + queryItems = qs.split(separator: "&").map { param in + let kv = param.split(separator: "=", maxSplits: 1) + let key = String(kv[0]) + let val = kv.count > 1 ? (String(kv[1]).removingPercentEncoding ?? String(kv[1])) : "" + return URLQueryItem(name: key, value: val) + } + } + + let ext = parseQueryItems(queryItems, dbType: dbType) + + return ParsedConnectionURL( + type: dbType, + host: firstHost, + port: firstPort, + database: database, + username: username, + password: password, + sslMode: ext.sslMode, + authSource: ext.authSource, + sshHost: nil, + sshPort: nil, + sshUsername: nil, + usePrivateKey: nil, + useSSHAgent: nil, + agentSocket: nil, + connectionName: ext.connectionName, + redisDatabase: nil, + statusColor: ext.statusColor, + envTag: ext.envTag, + schema: ext.schema, + tableName: ext.tableName, + isView: ext.isView, + filterColumn: ext.filterColumn, + filterOperation: ext.filterOperation, + filterValue: ext.filterValue, + filterCondition: ext.filterCondition, + oracleServiceName: nil, + useSrv: isSrv, + mongoQueryParams: ext.mongoQueryParams, + multiHost: multiHost + ) + } + // MARK: - Query Parameter Helpers private struct ExtendedParams { diff --git a/TablePro/Models/Connection/DatabaseConnection.swift b/TablePro/Models/Connection/DatabaseConnection.swift index 4bb549d99..7b47fb7cc 100644 --- a/TablePro/Models/Connection/DatabaseConnection.swift +++ b/TablePro/Models/Connection/DatabaseConnection.swift @@ -367,6 +367,18 @@ extension DatabaseConnection { static let preview = DatabaseConnection(name: "Preview Connection") } +// MARK: - Display Helpers + +extension DatabaseConnection { + var hostDisplayString: String { + if let mongoHosts = additionalFields["mongoHosts"], mongoHosts.contains(",") { + let count = mongoHosts.split(separator: ",").count + return String(format: String(localized: "%@ (+%d more)"), "\(host):\(port)", count - 1) + } + return "\(host):\(port)" + } +} + // MARK: - Codable Conformance extension DatabaseConnection: Codable { diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 531c30475..f0d762c87 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -1413,6 +1413,16 @@ } } }, + "%d of %d rows" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d of %2$d rows" + } + } + } + }, "%d of %d rows selected" : { "localizations" : { "en" : { @@ -1462,6 +1472,9 @@ } } } + }, + "%d rows" : { + }, "%d-%d of %@%@ rows" : { "localizations" : { @@ -2153,15 +2166,8 @@ } } }, - "%lld/%lld" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "%1$lld/%2$lld" - } - } - } + "%lld/5" : { + }, "%lld%%" : { "localizations" : { @@ -10383,6 +10389,9 @@ } } } + }, + "Copy JSON" : { + }, "Copy Key" : { "localizations" : { @@ -15840,6 +15849,9 @@ } } } + }, + "Execute a query to view results as JSON" : { + }, "Execute All" : { "localizations" : { @@ -20924,9 +20936,6 @@ } } } - }, - "Includes app version, macOS version, and installed plugins only." : { - }, "Incorrect passphrase" : { "localizations" : { @@ -26040,6 +26049,9 @@ } } } + }, + "No Data" : { + }, "No database connection" : { "localizations" : { @@ -29242,6 +29254,9 @@ } } } + }, + "Path" : { + }, "Pause" : { "localizations" : { @@ -29877,6 +29892,7 @@ } }, "postgresql://user:password@host:5432/database" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -35792,6 +35808,9 @@ } } } + }, + "Service" : { + }, "Service Account Key" : { "localizations" : { @@ -40123,6 +40142,9 @@ } } } + }, + "The text could not be parsed as JSON." : { + }, "The text could not be parsed as JSON. Use text mode to view or edit." : { "localizations" : { diff --git a/TablePro/ViewModels/WelcomeViewModel.swift b/TablePro/ViewModels/WelcomeViewModel.swift index 3533d2ddf..a6f25c17e 100644 --- a/TablePro/ViewModels/WelcomeViewModel.swift +++ b/TablePro/ViewModels/WelcomeViewModel.swift @@ -43,6 +43,8 @@ final class WelcomeViewModel { var showOnboarding = !AppSettingsStorage.shared.hasCompletedOnboarding() var connectionsToDelete: [DatabaseConnection] = [] var showDeleteConfirmation = false + var showDeleteGroupConfirmation = false + var groupToDelete: ConnectionGroup? var pendingMoveToNewGroup: [DatabaseConnection] = [] var activeSheet: WelcomeActiveSheet? var pluginInstallConnection: DatabaseConnection? @@ -315,8 +317,15 @@ final class WelcomeViewModel { // MARK: - Groups - func deleteGroup(_ group: ConnectionGroup) { + func requestDeleteGroup(_ group: ConnectionGroup) { + groupToDelete = group + showDeleteGroupConfirmation = true + } + + func confirmDeleteGroup() { + guard let group = groupToDelete else { return } groupStorage.deleteGroup(group) + groupToDelete = nil loadConnections() } diff --git a/TablePro/Views/Connection/ConnectionFieldRow.swift b/TablePro/Views/Connection/ConnectionFieldRow.swift index 17b569568..61b81a5ce 100644 --- a/TablePro/Views/Connection/ConnectionFieldRow.swift +++ b/TablePro/Views/Connection/ConnectionFieldRow.swift @@ -61,6 +61,8 @@ struct ConnectionFieldRow: View { ) { Text("\(field.label): \(Int(value) ?? range.lowerBound)") } + case .hostList: + EmptyView() } } } diff --git a/TablePro/Views/Connection/ConnectionFormView+Footer.swift b/TablePro/Views/Connection/ConnectionFormView+Footer.swift index 0b73bd60b..d4894a755 100644 --- a/TablePro/Views/Connection/ConnectionFormView+Footer.swift +++ b/TablePro/Views/Connection/ConnectionFormView+Footer.swift @@ -199,7 +199,9 @@ extension ConnectionFormView { previewRow(String(localized: "Host"), parsed.host) } case .network: - if !parsed.host.isEmpty { + if let multiHost = parsed.multiHost, multiHost.contains(",") { + previewRow(String(localized: "Hosts"), multiHost) + } else if !parsed.host.isEmpty { let portStr = parsed.port.map { ":\($0)" } ?? "" previewRow(String(localized: "Host"), parsed.host + portStr) } diff --git a/TablePro/Views/Connection/ConnectionFormView+GeneralTab.swift b/TablePro/Views/Connection/ConnectionFormView+GeneralTab.swift index 8de2d8b74..c7b00c446 100644 --- a/TablePro/Views/Connection/ConnectionFormView+GeneralTab.swift +++ b/TablePro/Views/Connection/ConnectionFormView+GeneralTab.swift @@ -108,16 +108,7 @@ extension ConnectionFormView { } } else { Section(String(localized: "Connection")) { - TextField( - String(localized: "Host"), - text: $host, - prompt: Text("localhost") - ) - TextField( - String(localized: "Port"), - text: $port, - prompt: Text(defaultPort) - ) + hostFieldsView if PluginManager.shared.requiresAuthentication(for: type) { TextField( String(localized: "Database"), @@ -126,6 +117,24 @@ extension ConnectionFormView { ) } } + + if sshState.enabled && hasHostListField { + let hostListFieldId = connectionSectionFields.first { + if case .hostList = $0.fieldType { return true } + return false + }?.id + let hostsValue = hostListFieldId.flatMap { additionalFieldValues[$0] } ?? "" + if hostsValue.contains(",") { + Section { + Label( + String(localized: "SSH tunneling only forwards the first host. Other replica set members must be directly reachable from the SSH server."), + systemImage: "exclamationmark.triangle" + ) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } } if PluginManager.shared.connectionMode(for: type) != .fileBased { @@ -215,4 +224,37 @@ extension ConnectionFormView { LicenseActivationSheet() } } + + @ViewBuilder + private var hostFieldsView: some View { + if hasHostListField { + ForEach(connectionSectionFields, id: \.id) { field in + if case .hostList = field.fieldType { + HostListFieldRow( + label: field.label, + placeholder: field.placeholder, + defaultPort: type.defaultPort, + value: Binding( + get: { + additionalFieldValues[field.id] + ?? field.defaultValue ?? "" + }, + set: { additionalFieldValues[field.id] = $0 } + ) + ) + } + } + } else { + TextField( + String(localized: "Host"), + text: $host, + prompt: Text("localhost") + ) + TextField( + String(localized: "Port"), + text: $port, + prompt: Text(defaultPort) + ) + } + } } diff --git a/TablePro/Views/Connection/ConnectionFormView+Helpers.swift b/TablePro/Views/Connection/ConnectionFormView+Helpers.swift index e8e80a370..5a0268bce 100644 --- a/TablePro/Views/Connection/ConnectionFormView+Helpers.swift +++ b/TablePro/Views/Connection/ConnectionFormView+Helpers.swift @@ -132,6 +132,14 @@ extension ConnectionFormView { additionalFieldValues["redisDatabase"] = String(rdb) } + // Synthesize mongoHosts from host:port for existing MongoDB connections + if existing.type.pluginTypeId == "MongoDB", + additionalFieldValues["mongoHosts"]?.isEmpty != false + { + let existingHost = existing.host.isEmpty ? "localhost" : existing.host + additionalFieldValues["mongoHosts"] = "\(existingHost):\(existing.port)" + } + for field in PluginManager.shared.additionalConnectionFields(for: existing.type) { if additionalFieldValues[field.id] == nil, let defaultValue = field.defaultValue { additionalFieldValues[field.id] = defaultValue @@ -170,8 +178,8 @@ extension ConnectionFormView { clientKeyPath: sslClientKeyPath ) - let finalHost = host.trimmingCharacters(in: .whitespaces).isEmpty ? "localhost" : host - let finalPort = Int(port) ?? type.defaultPort + var finalHost = host.trimmingCharacters(in: .whitespaces).isEmpty ? "localhost" : host + var finalPort = Int(port) ?? type.defaultPort let trimmedUsername = username.trimmingCharacters(in: .whitespaces) let finalUsername = trimmedUsername.isEmpty && PluginManager.shared.requiresAuthentication(for: type) @@ -180,6 +188,16 @@ extension ConnectionFormView { let finalId = connectionId ?? UUID() var finalAdditionalFields = additionalFieldValues + + if type.pluginTypeId == "MongoDB", + let mongoHosts = finalAdditionalFields["mongoHosts"], + !mongoHosts.isEmpty + { + let result = Self.normalizeMongoHosts(mongoHosts, defaultPort: type.defaultPort) + finalAdditionalFields["mongoHosts"] = result.hosts + finalHost = result.primaryHost + finalPort = result.primaryPort + } let trimmedScript = preConnectScript.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmedScript.isEmpty { finalAdditionalFields["preConnectScript"] = preConnectScript @@ -361,8 +379,8 @@ extension ConnectionFormView { clientKeyPath: sslClientKeyPath ) - let finalHost = host.trimmingCharacters(in: .whitespaces).isEmpty ? "localhost" : host - let finalPort = Int(port) ?? type.defaultPort + var testHost = host.trimmingCharacters(in: .whitespaces).isEmpty ? "localhost" : host + var testPort = Int(port) ?? type.defaultPort let trimmedUsername = username.trimmingCharacters(in: .whitespaces) let finalUsername = trimmedUsername.isEmpty && PluginManager.shared.requiresAuthentication(for: type) @@ -376,11 +394,21 @@ extension ConnectionFormView { finalAdditionalFields.removeValue(forKey: "preConnectScript") } + if type.pluginTypeId == "MongoDB", + let mongoHosts = finalAdditionalFields["mongoHosts"], + !mongoHosts.isEmpty + { + let result = Self.normalizeMongoHosts(mongoHosts, defaultPort: type.defaultPort) + finalAdditionalFields["mongoHosts"] = result.hosts + testHost = result.primaryHost + testPort = result.primaryPort + } + let testTunnelMode = sshState.buildTunnelMode() let testConn = DatabaseConnection( name: name, - host: finalHost, - port: finalPort, + host: testHost, + port: testPort, database: database, username: finalUsername, type: type, @@ -544,9 +572,16 @@ extension ConnectionFormView { sshState.applyAgentSocketPath(parsed.agentSocket ?? "") } } + // Multi-host MongoDB support + if let multiHost = parsed.multiHost, !multiHost.isEmpty { + additionalFieldValues["mongoHosts"] = multiHost + } else if parsed.type.pluginTypeId == "MongoDB" { + let portStr = parsed.port.map(String.init) ?? String(parsed.type.defaultPort) + additionalFieldValues["mongoHosts"] = "\(parsed.host):\(portStr)" + } // Clear stale MongoDB fields before applying new import let mongoKeys = additionalFieldValues.keys.filter { - $0.hasPrefix("mongo") || $0.hasPrefix("mongoParam_") + ($0.hasPrefix("mongo") || $0.hasPrefix("mongoParam_")) && $0 != "mongoHosts" } for key in mongoKeys { additionalFieldValues.removeValue(forKey: key) @@ -603,6 +638,39 @@ extension ConnectionFormView { } } +// MARK: - Multi-Host Helpers + +extension ConnectionFormView { + struct NormalizedHosts { + let hosts: String + let primaryHost: String + let primaryPort: Int + } + + static func normalizeMongoHosts(_ raw: String, defaultPort: Int) -> NormalizedHosts { + let normalized = raw.split(separator: ",", omittingEmptySubsequences: false) + .map { segment -> String in + let trimmed = segment.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { return "localhost:\(defaultPort)" } + if !trimmed.contains(":") { return "\(trimmed):\(defaultPort)" } + return trimmed + } + .joined(separator: ",") + let firstSegment = normalized.split(separator: ",").first.map(String.init) ?? normalized + let parts = firstSegment.split(separator: ":", maxSplits: 1) + var host = "localhost" + var port = defaultPort + if let first = parts.first { + let derived = String(first).trimmingCharacters(in: .whitespaces) + if !derived.isEmpty { host = derived } + } + if parts.count > 1, let p = Int(parts[1].trimmingCharacters(in: .whitespaces)) { + port = p + } + return NormalizedHosts(hosts: normalized, primaryHost: host, primaryPort: port) + } +} + // MARK: - Test Helpers extension ConnectionFormView { diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index e13e58fc6..de8b579fa 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -35,6 +35,18 @@ struct ConnectionFormView: View { .filter { $0.section == .authentication } } + var connectionSectionFields: [ConnectionField] { + PluginManager.shared.additionalConnectionFields(for: type) + .filter { $0.section == .connection } + } + + var hasHostListField: Bool { + connectionSectionFields.contains { field in + if case .hostList = field.fieldType { return true } + return false + } + } + func isFieldVisible(_ field: ConnectionField) -> Bool { guard let rule = field.visibleWhen else { return true } let currentValue = additionalFieldValues[rule.fieldId] ?? defaultFieldValue(rule.fieldId) diff --git a/TablePro/Views/Connection/HostListFieldRow.swift b/TablePro/Views/Connection/HostListFieldRow.swift new file mode 100644 index 000000000..a2b752bbf --- /dev/null +++ b/TablePro/Views/Connection/HostListFieldRow.swift @@ -0,0 +1,137 @@ +// +// HostListFieldRow.swift +// TablePro +// + +import SwiftUI + +struct HostEntry: Identifiable { + let id = UUID() + var value: String +} + +struct HostListFieldRow: View { + let label: String + let placeholder: String + let defaultPort: Int + @Binding var value: String + + @State private var entries: [HostEntry] = [] + @State private var selectedId: Set = [] + + var body: some View { + LabeledContent { + List(selection: $selectedId) { + ForEach(entries) { entry in + TextField("", text: bindingForEntry(entry), prompt: Text("hostname:\(defaultPort)")) + .tag(entry.id) + } + } + .listStyle(.bordered(alternatesRowBackgrounds: false)) + .frame(height: listHeight) + .safeAreaInset(edge: .bottom, spacing: 0) { + VStack(spacing: 0) { + Divider() + HStack(spacing: 0) { + Button { addEntry() } label: { + Image(systemName: "plus") + .frame(width: 24, height: 20) + } + .buttonStyle(.borderless) + + Divider().frame(height: 14) + + Button { removeSelected() } label: { + Image(systemName: "minus") + .frame(width: 24, height: 20) + } + .buttonStyle(.borderless) + .disabled(selectedId.isEmpty || entries.count <= 1) + + Spacer() + } + .padding(.horizontal, 4) + .padding(.vertical, 2) + } + .background(.bar) + } + } label: { + Text(label) + } + .onAppear { parseValue() } + .onChange(of: value) { parseValue() } + } + + private var listHeight: CGFloat { + let rowHeight: CGFloat = 24 + let rows = CGFloat(max(entries.count, 1)) + let buttonBarHeight: CGFloat = 28 + return min(rows * rowHeight + buttonBarHeight + 8, 140) + } + + private func bindingForEntry(_ entry: HostEntry) -> Binding { + Binding( + get: { + entries.first { $0.id == entry.id }?.value ?? "" + }, + set: { newValue in + if let idx = entries.firstIndex(where: { $0.id == entry.id }) { + entries[idx].value = newValue + syncValue() + } + } + ) + } + + private func parseValue() { + let parsed = Self.parseHosts(value, defaultPort: defaultPort) + if !entriesMatch(parsed) { + entries = parsed + } + } + + private func entriesMatch(_ parsed: [HostEntry]) -> Bool { + guard entries.count == parsed.count else { return false } + for (existing, new) in zip(entries, parsed) { + if existing.value != new.value { return false } + } + return true + } + + private func syncValue() { + let result = entries.map { entry -> String in + entry.value.trimmingCharacters(in: .whitespaces) + }.joined(separator: ",") + if value != result { + value = result + } + } + + private func addEntry() { + let newEntry = HostEntry(value: "") + entries.append(newEntry) + selectedId = [newEntry.id] + syncValue() + } + + private func removeSelected() { + guard !selectedId.isEmpty, entries.count > 1 else { return } + entries.removeAll { selectedId.contains($0.id) } + if entries.isEmpty { + entries.append(HostEntry(value: "")) + } + selectedId = [] + syncValue() + } + + static func parseHosts(_ value: String, defaultPort: Int) -> [HostEntry] { + guard !value.isEmpty else { + return [HostEntry(value: "")] + } + let parts = value.split(separator: ",", omittingEmptySubsequences: false) + let result = parts.map { part in + HostEntry(value: String(part).trimmingCharacters(in: .whitespaces)) + } + return result.isEmpty ? [HostEntry(value: "")] : result + } +} diff --git a/TablePro/Views/Connection/ImportFromApp/ConnectionImportPreviewList.swift b/TablePro/Views/Connection/ImportFromApp/ConnectionImportPreviewList.swift index dfc22b015..f84e55575 100644 --- a/TablePro/Views/Connection/ImportFromApp/ConnectionImportPreviewList.swift +++ b/TablePro/Views/Connection/ImportFromApp/ConnectionImportPreviewList.swift @@ -57,7 +57,7 @@ struct ConnectionImportPreviewList: View { } } HStack(spacing: 0) { - Text("\(item.connection.host):\(String(item.connection.port))") + Text("\(item.connection.host):\(item.connection.port)") warningText(for: item.status) } .font(.subheadline) diff --git a/TablePro/Views/Connection/WelcomeConnectionRow.swift b/TablePro/Views/Connection/WelcomeConnectionRow.swift index 1fb036daa..715dbcb66 100644 --- a/TablePro/Views/Connection/WelcomeConnectionRow.swift +++ b/TablePro/Views/Connection/WelcomeConnectionRow.swift @@ -76,6 +76,13 @@ struct WelcomeConnectionRow: View { if connection.host.isEmpty { return connection.database.isEmpty ? connection.type.rawValue : connection.database } + if let mongoHosts = connection.additionalFields["mongoHosts"], mongoHosts.contains(",") { + let count = mongoHosts.split(separator: ",").count + return String( + format: String(localized: "%@ (+%d more)"), + "\(connection.host):\(connection.port)", count - 1 + ) + } return connection.host } } diff --git a/TablePro/Views/Connection/WelcomeWindowView.swift b/TablePro/Views/Connection/WelcomeWindowView.swift index 775018cd1..1fd5e7c41 100644 --- a/TablePro/Views/Connection/WelcomeWindowView.swift +++ b/TablePro/Views/Connection/WelcomeWindowView.swift @@ -58,6 +58,21 @@ struct WelcomeWindowView: View { Text("Are you sure you want to delete \(vm.connectionsToDelete.count) connections? This cannot be undone.") } } + .alert( + String(localized: "Delete Group"), + isPresented: $vm.showDeleteGroupConfirmation + ) { + Button(String(localized: "Delete"), role: .destructive) { + vm.confirmDeleteGroup() + } + Button(String(localized: "Cancel"), role: .cancel) { + vm.groupToDelete = nil + } + } message: { + if let group = vm.groupToDelete { + Text("Are you sure you want to delete the group \"\(group.name)\"? Connections in this group will be moved to the top level.") + } + } .sheet(item: $vm.activeSheet) { sheet in switch sheet { case .newGroup(let parentId): @@ -357,7 +372,7 @@ struct WelcomeWindowView: View { VStack(alignment: .leading, spacing: 2) { Text(linked.connection.name) .lineLimit(1) - Text("\(linked.connection.host):\(String(linked.connection.port))") + Text("\(linked.connection.host):\(linked.connection.port)") .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) @@ -493,7 +508,7 @@ struct WelcomeWindowView: View { Divider() Button(role: .destructive) { - vm.deleteGroup(group) + vm.requestDeleteGroup(group) } label: { Label(String(localized: "Delete Group"), systemImage: "trash") } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Alerts.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Alerts.swift index b25fe262c..cc189bd08 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Alerts.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Alerts.swift @@ -59,9 +59,10 @@ extension MainContentCoordinator { } let queryList = querySummaries.joined(separator: "\n") - let message = String( - localized: "The following \(statements.count) queries may permanently modify or delete data. This action cannot be undone.\n\n\(queryList)" + let format = String( + localized: "The following %d queries may permanently modify or delete data. This action cannot be undone.\n\n%@" ) + let message = String(format: format, statements.count, queryList) return await AlertHelper.confirmCritical( title: String(localized: "Potentially Dangerous Queries"), diff --git a/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift b/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift index d105ebb30..b52cd6687 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift @@ -112,7 +112,7 @@ extension MainContentView { for row in displayRows { let values = columns.indices.map { i in let raw = i < row.count ? (row[i] ?? "NULL") : "NULL" - return raw.count > 200 ? String(raw.prefix(200)) + "..." : raw + return (raw as NSString).length > 200 ? String(raw.prefix(200)) + "..." : raw } lines.append(values.joined(separator: " | ")) } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 183a7250b..9c8b438fa 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -621,7 +621,10 @@ final class MainContentCoordinator { // Never-activated coordinators are throwaway instances created by SwiftUI // during body re-evaluation — @State only keeps the first, rest are discarded guard _didActivate.withLock({ $0 }) else { - MainActor.assumeIsolated { unregisterFromPersistence() } + let id = ObjectIdentifier(self) + Task { @MainActor in + Self.activeCoordinators.removeValue(forKey: id) + } if !alreadyHandled { Task { @MainActor in SchemaProviderRegistry.shared.release(for: connectionId) diff --git a/TableProTests/Core/Utilities/DatabaseURLSchemeTests.swift b/TableProTests/Core/Utilities/DatabaseURLSchemeTests.swift index d40ce5395..d0d67ed77 100644 --- a/TableProTests/Core/Utilities/DatabaseURLSchemeTests.swift +++ b/TableProTests/Core/Utilities/DatabaseURLSchemeTests.swift @@ -296,4 +296,54 @@ struct DatabaseURLSchemeTests { } #expect(parsed.type == .postgresql) } + + // MARK: - MongoDB Multi-Host + + @Test("MongoDB multi-host URI parses all hosts") + func mongodbMultiHost() { + let result = ConnectionURLParser.parse("mongodb://h1:27017,h2:27018,h3:27019/mydb?replicaSet=rs0") + guard case .success(let parsed) = result else { + Issue.record("Expected success, got: \(result)"); return + } + #expect(parsed.type == .mongodb) + #expect(parsed.host == "h1") + #expect(parsed.port == 27017) + #expect(parsed.database == "mydb") + #expect(parsed.multiHost == "h1:27017,h2:27018,h3:27019") + #expect(parsed.mongoQueryParams["replicaSet"] == "rs0") + } + + @Test("MongoDB multi-host with credentials parses correctly") + func mongodbMultiHostWithAuth() { + let result = ConnectionURLParser.parse("mongodb://admin:secret@h1:27017,h2:27017/testdb") + guard case .success(let parsed) = result else { + Issue.record("Expected success, got: \(result)"); return + } + #expect(parsed.host == "h1") + #expect(parsed.username == "admin") + #expect(parsed.password == "secret") + #expect(parsed.database == "testdb") + #expect(parsed.multiHost == "h1:27017,h2:27017") + } + + @Test("MongoDB single-host falls through to standard parser") + func mongodbSingleHostNoMultiHost() { + let result = ConnectionURLParser.parse("mongodb://user:pass@localhost:27017/mydb") + guard case .success(let parsed) = result else { + Issue.record("Expected success"); return + } + #expect(parsed.host == "localhost") + #expect(parsed.multiHost == nil) + } + + @Test("MongoDB multi-host without port uses default") + func mongodbMultiHostDefaultPort() { + let result = ConnectionURLParser.parse("mongodb://h1,h2:27018/db") + guard case .success(let parsed) = result else { + Issue.record("Expected success, got: \(result)"); return + } + #expect(parsed.host == "h1") + #expect(parsed.port == nil) + #expect(parsed.multiHost == "h1,h2:27018") + } } diff --git a/docs/databases/mongodb.mdx b/docs/databases/mongodb.mdx index 3aa27cd46..5e48ab59d 100644 --- a/docs/databases/mongodb.mdx +++ b/docs/databases/mongodb.mdx @@ -22,8 +22,7 @@ TablePro supports MongoDB 5.0 and later. Collections appear as tables in the sid | Field | Default | Notes | |-------|---------|-------| -| **Host** | `localhost` | | -| **Port** | `27017` | | +| **Hosts** | `localhost:27017` | Add multiple hosts for replica sets | | **Database** | - | Database name to connect to | | **Username** | - | Leave empty for local dev without auth | | **Password** | - | | @@ -45,6 +44,22 @@ TablePro supports MongoDB 5.0 and later. Collections appear as tables in the sid /> +## Replica Sets + +The **Hosts** field supports multiple entries. Click **Add Host** to add replica set members, each with its own port. + +Set the replica set name in the **Advanced** tab. + +You can also paste a multi-host URI directly: + +``` +mongodb://host1:27017,host2:27017,host3:27017/mydb?replicaSet=myrs +``` + + +SSH tunneling only forwards the first host. Other members must be reachable from the SSH server. + + ## Example Configurations **Local**: host `localhost:27017`, no auth, database `myapp`