From a63f8de9628449b820b128128e2119b92f31c2b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 24 Apr 2026 16:26:00 +0700 Subject: [PATCH 01/22] feat: add MongoDB replica set multi-host connection support --- CHANGELOG.md | 1 + .../MongoDBConnection.swift | 18 ++- .../MongoDBDriverPlugin/MongoDBPlugin.swift | 7 + .../MongoDBPluginDriver.swift | 5 +- .../TableProPluginKit/ConnectionField.swift | 2 + ...ginMetadataRegistry+RegistryDefaults.swift | 7 + .../Connection/ConnectionURLParser.swift | 121 +++++++++++++++++- .../Models/Connection/ConnectionExport.swift | 9 ++ .../Connection/DatabaseConnection.swift | 13 ++ .../Views/Connection/ConnectionFieldRow.swift | 7 + .../ConnectionFormView+GeneralTab.swift | 51 ++++++-- .../ConnectionFormView+Helpers.swift | 60 ++++++++- .../Views/Connection/ConnectionFormView.swift | 12 ++ .../Views/Connection/HostListFieldRow.swift | 120 +++++++++++++++++ .../ConnectionImportPreviewList.swift | 2 +- .../Connection/WelcomeConnectionRow.swift | 4 + .../Views/Connection/WelcomeWindowView.swift | 2 +- docs/databases/mongodb.mdx | 23 +++- 18 files changed, 433 insertions(+), 31 deletions(-) create mode 100644 TablePro/Views/Connection/HostListFieldRow.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index b4e4cb9b0..eab3d2101 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 replica set support with multi-host connections - In-app feedback form for bug reports and feature requests via Help > Report an Issue - Per-connection "Local only" option to exclude individual connections from iCloud sync - Filter operator picker shows SQL symbols alongside names for quick visual recognition diff --git a/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift b/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift index 3fcc490bd..53099638f 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift @@ -177,14 +177,26 @@ 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: ",").map { segment -> String in + let parts = segment.split(separator: ":", maxSplits: 1) + let h = String(parts[0]).trimmingCharacters(in: .whitespaces) + let encodedH = h.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? h + if parts.count > 1 { + return "\(encodedH):\(parts[1].trimmingCharacters(in: .whitespaces))" + } + return "\(encodedH):\(port)" + } + uri += 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/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 b4f1f5190..1f858c19d 100644 --- a/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift +++ b/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift @@ -34,18 +34,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 } @@ -159,7 +161,8 @@ struct ConnectionURLParser { filterCondition: nil, oracleServiceName: nil, useSrv: false, - mongoQueryParams: [:] + mongoQueryParams: [:], + multiHost: nil )) } @@ -167,6 +170,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) @@ -249,7 +260,8 @@ struct ConnectionURLParser { filterCondition: ext.filterCondition, oracleServiceName: oracleServiceName, useSrv: ext.useSrv, - mongoQueryParams: ext.mongoQueryParams + mongoQueryParams: ext.mongoQueryParams, + multiHost: nil )) } @@ -382,10 +394,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/ConnectionExport.swift b/TablePro/Models/Connection/ConnectionExport.swift index 0b112b43c..ecaa3c210 100644 --- a/TablePro/Models/Connection/ConnectionExport.swift +++ b/TablePro/Models/Connection/ConnectionExport.swift @@ -58,6 +58,15 @@ struct ExportableConnection: Codable { let redisDatabase: Int? let startupCommands: String? let localOnly: Bool? + + var hostDisplayString: String { + if let mongoHosts = additionalFields?["mongoHosts"], mongoHosts.contains(",") { + let count = mongoHosts.split(separator: ",").count + let firstHost = mongoHosts.split(separator: ",").first.map(String.init) ?? host + return String(format: String(localized: "%@ (+%d more)"), firstHost, count - 1) + } + return "\(host):\(port)" + } } // MARK: - SSH Config diff --git a/TablePro/Models/Connection/DatabaseConnection.swift b/TablePro/Models/Connection/DatabaseConnection.swift index 4bb549d99..77c6c9eea 100644 --- a/TablePro/Models/Connection/DatabaseConnection.swift +++ b/TablePro/Models/Connection/DatabaseConnection.swift @@ -367,6 +367,19 @@ 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 + let firstHost = mongoHosts.split(separator: ",").first.map(String.init) ?? host + return String(format: String(localized: "%@ (+%d more)"), firstHost, count - 1) + } + return "\(host):\(port)" + } +} + // MARK: - Codable Conformance extension DatabaseConnection: Codable { diff --git a/TablePro/Views/Connection/ConnectionFieldRow.swift b/TablePro/Views/Connection/ConnectionFieldRow.swift index 17b569568..e5fdf13e7 100644 --- a/TablePro/Views/Connection/ConnectionFieldRow.swift +++ b/TablePro/Views/Connection/ConnectionFieldRow.swift @@ -61,6 +61,13 @@ struct ConnectionFieldRow: View { ) { Text("\(field.label): \(Int(value) ?? range.lowerBound)") } + case .hostList: + HostListFieldRow( + label: field.label, + placeholder: field.placeholder, + defaultPort: Int(field.defaultValue ?? "27017") ?? 27_017, + value: $value + ) } } } diff --git a/TablePro/Views/Connection/ConnectionFormView+GeneralTab.swift b/TablePro/Views/Connection/ConnectionFormView+GeneralTab.swift index 8de2d8b74..749682f70 100644 --- a/TablePro/Views/Connection/ConnectionFormView+GeneralTab.swift +++ b/TablePro/Views/Connection/ConnectionFormView+GeneralTab.swift @@ -108,16 +108,33 @@ 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) - ) + if hasHostListField { + ForEach(connectionSectionFields, id: \.id) { field in + if case .hostList = field.fieldType { + ConnectionFieldRow( + field: field, + 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) + ) + } if PluginManager.shared.requiresAuthentication(for: type) { TextField( String(localized: "Database"), @@ -126,6 +143,20 @@ extension ConnectionFormView { ) } } + + if sshState.enabled && hasHostListField { + let hostsValue = additionalFieldValues["mongoHosts"] ?? "" + 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 { diff --git a/TablePro/Views/Connection/ConnectionFormView+Helpers.swift b/TablePro/Views/Connection/ConnectionFormView+Helpers.swift index 34c0672b5..2f775df29 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,22 @@ extension ConnectionFormView { let finalId = connectionId ?? UUID() var finalAdditionalFields = additionalFieldValues + + // Derive primary host/port from mongoHosts for display and storage + if type.pluginTypeId == "MongoDB", + let mongoHosts = finalAdditionalFields["mongoHosts"], + !mongoHosts.isEmpty + { + let firstSegment = mongoHosts.split(separator: ",").first.map(String.init) ?? mongoHosts + let parts = firstSegment.split(separator: ":", maxSplits: 1) + if !parts.isEmpty { + let derived = String(parts[0]).trimmingCharacters(in: .whitespaces) + finalHost = derived.isEmpty ? "localhost" : derived + } + if parts.count > 1, let p = Int(parts[1].trimmingCharacters(in: .whitespaces)) { + finalPort = p + } + } let trimmedScript = preConnectScript.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmedScript.isEmpty { finalAdditionalFields["preConnectScript"] = preConnectScript @@ -361,8 +385,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 +400,26 @@ extension ConnectionFormView { finalAdditionalFields.removeValue(forKey: "preConnectScript") } + if type.pluginTypeId == "MongoDB", + let mongoHosts = finalAdditionalFields["mongoHosts"], + !mongoHosts.isEmpty + { + let firstSegment = mongoHosts.split(separator: ",").first.map(String.init) ?? mongoHosts + let parts = firstSegment.split(separator: ":", maxSplits: 1) + if !parts.isEmpty { + let derived = String(parts[0]).trimmingCharacters(in: .whitespaces) + testHost = derived.isEmpty ? "localhost" : derived + } + if parts.count > 1, let p = Int(parts[1].trimmingCharacters(in: .whitespaces)) { + testPort = p + } + } + 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 +583,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) 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..691d58b58 --- /dev/null +++ b/TablePro/Views/Connection/HostListFieldRow.swift @@ -0,0 +1,120 @@ +// +// HostListFieldRow.swift +// TablePro +// + +import SwiftUI + +struct HostEntry: Identifiable { + let id = UUID() + var host: String + var port: String +} + +struct HostListFieldRow: View { + let label: String + let placeholder: String + let defaultPort: Int + @Binding var value: String + + @State private var entries: [HostEntry] = [] + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + ForEach($entries) { $entry in + HStack(spacing: 4) { + TextField( + "Host", + text: $entry.host, + prompt: Text("localhost") + ) + .onChange(of: entry.host) { syncValue() } + + TextField( + "Port", + text: $entry.port, + prompt: Text(String(defaultPort)) + ) + .frame(width: 64) + .onChange(of: entry.port) { syncValue() } + + Button { + removeEntry(entry) + } label: { + Image(systemName: "minus.circle") + } + .buttonStyle(.plain) + .disabled(entries.count <= 1) + .opacity(entries.count <= 1 ? 0.3 : 1) + } + } + + Button { + addEntry() + } label: { + Label("Add Host", systemImage: "plus.circle") + .font(.caption) + } + .buttonStyle(.plain) + } + .onAppear { parseValue() } + .onChange(of: value) { parseValue() } + } + + 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.host != new.host || existing.port != new.port { return false } + } + return true + } + + private func syncValue() { + let result = entries.map { entry -> String in + let h = entry.host.trimmingCharacters(in: .whitespaces) + let p = entry.port.trimmingCharacters(in: .whitespaces) + let effectiveHost = h.isEmpty ? "localhost" : h + let effectivePort = p.isEmpty ? String(defaultPort) : p + return "\(effectiveHost):\(effectivePort)" + }.joined(separator: ",") + if value != result { + value = result + } + } + + private func addEntry() { + entries.append(HostEntry(host: "", port: String(defaultPort))) + syncValue() + } + + private func removeEntry(_ entry: HostEntry) { + entries.removeAll { $0.id == entry.id } + if entries.isEmpty { + entries.append(HostEntry(host: "", port: String(defaultPort))) + } + syncValue() + } + + static func parseHosts(_ value: String, defaultPort: Int) -> [HostEntry] { + guard !value.isEmpty else { + return [HostEntry(host: "", port: String(defaultPort))] + } + let parts = value.split(separator: ",", omittingEmptySubsequences: false) + var result: [HostEntry] = [] + for part in parts { + let trimmed = part.trimmingCharacters(in: .whitespaces) + let components = trimmed.split(separator: ":", maxSplits: 1) + let host = components.isEmpty ? "" : String(components[0]) + let port = components.count > 1 ? String(components[1]) : String(defaultPort) + result.append(HostEntry(host: host, port: port)) + } + return result.isEmpty ? [HostEntry(host: "", port: String(defaultPort))] : result + } +} diff --git a/TablePro/Views/Connection/ImportFromApp/ConnectionImportPreviewList.swift b/TablePro/Views/Connection/ImportFromApp/ConnectionImportPreviewList.swift index dfc22b015..b8ba3439e 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.hostDisplayString) warningText(for: item.status) } .font(.subheadline) diff --git a/TablePro/Views/Connection/WelcomeConnectionRow.swift b/TablePro/Views/Connection/WelcomeConnectionRow.swift index 1fb036daa..2fa4cdea0 100644 --- a/TablePro/Views/Connection/WelcomeConnectionRow.swift +++ b/TablePro/Views/Connection/WelcomeConnectionRow.swift @@ -76,6 +76,10 @@ 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, count - 1) + } return connection.host } } diff --git a/TablePro/Views/Connection/WelcomeWindowView.swift b/TablePro/Views/Connection/WelcomeWindowView.swift index 775018cd1..8a5371699 100644 --- a/TablePro/Views/Connection/WelcomeWindowView.swift +++ b/TablePro/Views/Connection/WelcomeWindowView.swift @@ -357,7 +357,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.hostDisplayString) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) diff --git a/docs/databases/mongodb.mdx b/docs/databases/mongodb.mdx index 50daef1be..e3770e066 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,26 @@ TablePro supports MongoDB 5.0 and later. Collections appear as tables in the sid /> +## Replica Set / Multi-Host + +For replica set connections with multiple hosts, use the **Hosts** field in the connection form. Click **Add Host** to add additional members. + +Each entry has its own host and port. The first host is used as the primary display name and for SSH tunnel forwarding. + +To import a multi-host URI, paste it in the URL import field: + +``` +mongodb://host1:27017,host2:27018,host3:27019/mydb?replicaSet=myrs +``` + +TablePro parses all hosts and populates the Hosts field automatically. + +Set the **Replica Set** name in the **Advanced** tab. Read Preference and Write Concern are also available there for tuning replica set read/write behavior. + + +SSH tunneling only forwards the first host. Other replica set members must be reachable directly from the SSH server. + + ## Example Configurations **Local**: host `localhost:27017`, no auth, database `myapp` From cc1927f11efb209d5c186870012d8ccf40d467ec Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Apr 2026 22:02:35 +0700 Subject: [PATCH 02/22] docs: trim MongoDB replica set section --- docs/databases/mongodb.mdx | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/docs/databases/mongodb.mdx b/docs/databases/mongodb.mdx index 09ee00684..5e48ab59d 100644 --- a/docs/databases/mongodb.mdx +++ b/docs/databases/mongodb.mdx @@ -44,24 +44,20 @@ TablePro supports MongoDB 5.0 and later. Collections appear as tables in the sid /> -## Replica Set / Multi-Host +## Replica Sets -For replica set connections with multiple hosts, use the **Hosts** field in the connection form. Click **Add Host** to add additional members. +The **Hosts** field supports multiple entries. Click **Add Host** to add replica set members, each with its own port. -Each entry has its own host and port. The first host is used as the primary display name and for SSH tunnel forwarding. +Set the replica set name in the **Advanced** tab. -To import a multi-host URI, paste it in the URL import field: +You can also paste a multi-host URI directly: ``` -mongodb://host1:27017,host2:27018,host3:27019/mydb?replicaSet=myrs +mongodb://host1:27017,host2:27017,host3:27017/mydb?replicaSet=myrs ``` -TablePro parses all hosts and populates the Hosts field automatically. - -Set the **Replica Set** name in the **Advanced** tab. Read Preference and Write Concern are also available there for tuning replica set read/write behavior. - -SSH tunneling only forwards the first host. Other replica set members must be reachable directly from the SSH server. +SSH tunneling only forwards the first host. Other members must be reachable from the SSH server. ## Example Configurations From 9ce24183dd1310dd0a43de3bfeca24f47dba2f7b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Apr 2026 22:07:43 +0700 Subject: [PATCH 03/22] fix: native macOS layout for multi-host field --- .../Views/Connection/HostListFieldRow.swift | 55 +++++++++---------- 1 file changed, 25 insertions(+), 30 deletions(-) diff --git a/TablePro/Views/Connection/HostListFieldRow.swift b/TablePro/Views/Connection/HostListFieldRow.swift index 691d58b58..c97e387de 100644 --- a/TablePro/Views/Connection/HostListFieldRow.swift +++ b/TablePro/Views/Connection/HostListFieldRow.swift @@ -20,42 +20,37 @@ struct HostListFieldRow: View { @State private var entries: [HostEntry] = [] var body: some View { - VStack(alignment: .leading, spacing: 6) { - ForEach($entries) { $entry in - HStack(spacing: 4) { - TextField( - "Host", - text: $entry.host, - prompt: Text("localhost") - ) - .onChange(of: entry.host) { syncValue() } + LabeledContent { + VStack(alignment: .leading, spacing: 6) { + ForEach($entries) { $entry in + HStack(spacing: 6) { + TextField("", text: $entry.host, prompt: Text("hostname")) + .onChange(of: entry.host) { syncValue() } - TextField( - "Port", - text: $entry.port, - prompt: Text(String(defaultPort)) - ) - .frame(width: 64) - .onChange(of: entry.port) { syncValue() } + TextField("", text: $entry.port, prompt: Text(String(defaultPort))) + .frame(width: 60) + .onChange(of: entry.port) { syncValue() } - Button { - removeEntry(entry) - } label: { - Image(systemName: "minus.circle") + Button { + removeEntry(entry) + } label: { + Image(systemName: "minus.circle.fill") + .foregroundStyle(.red) + } + .buttonStyle(.plain) + .disabled(entries.count <= 1) + .opacity(entries.count <= 1 ? 0.3 : 1) } - .buttonStyle(.plain) - .disabled(entries.count <= 1) - .opacity(entries.count <= 1 ? 0.3 : 1) } - } - Button { - addEntry() - } label: { - Label("Add Host", systemImage: "plus.circle") - .font(.caption) + Button { + addEntry() + } label: { + Label("Add Host", systemImage: "plus") + } } - .buttonStyle(.plain) + } label: { + Text(label) } .onAppear { parseValue() } .onChange(of: value) { parseValue() } From 8d1e921d602ef81a202c1bef434ffb4f09e0c7cc Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Apr 2026 22:22:54 +0700 Subject: [PATCH 04/22] fix: single host:port field per row for cleaner form layout --- .../Views/Connection/HostListFieldRow.swift | 47 +++++++++---------- 1 file changed, 22 insertions(+), 25 deletions(-) diff --git a/TablePro/Views/Connection/HostListFieldRow.swift b/TablePro/Views/Connection/HostListFieldRow.swift index c97e387de..5a93f9e2e 100644 --- a/TablePro/Views/Connection/HostListFieldRow.swift +++ b/TablePro/Views/Connection/HostListFieldRow.swift @@ -7,8 +7,7 @@ import SwiftUI struct HostEntry: Identifiable { let id = UUID() - var host: String - var port: String + var value: String } struct HostListFieldRow: View { @@ -24,12 +23,12 @@ struct HostListFieldRow: View { VStack(alignment: .leading, spacing: 6) { ForEach($entries) { $entry in HStack(spacing: 6) { - TextField("", text: $entry.host, prompt: Text("hostname")) - .onChange(of: entry.host) { syncValue() } - - TextField("", text: $entry.port, prompt: Text(String(defaultPort))) - .frame(width: 60) - .onChange(of: entry.port) { syncValue() } + TextField( + "", + text: $entry.value, + prompt: Text("hostname:\(defaultPort)") + ) + .onChange(of: entry.value) { syncValue() } Button { removeEntry(entry) @@ -66,18 +65,21 @@ struct HostListFieldRow: View { private func entriesMatch(_ parsed: [HostEntry]) -> Bool { guard entries.count == parsed.count else { return false } for (existing, new) in zip(entries, parsed) { - if existing.host != new.host || existing.port != new.port { return false } + if existing.value != new.value { return false } } return true } private func syncValue() { let result = entries.map { entry -> String in - let h = entry.host.trimmingCharacters(in: .whitespaces) - let p = entry.port.trimmingCharacters(in: .whitespaces) - let effectiveHost = h.isEmpty ? "localhost" : h - let effectivePort = p.isEmpty ? String(defaultPort) : p - return "\(effectiveHost):\(effectivePort)" + let trimmed = entry.value.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { + return "localhost:\(defaultPort)" + } + if !trimmed.contains(":") { + return "\(trimmed):\(defaultPort)" + } + return trimmed }.joined(separator: ",") if value != result { value = result @@ -85,31 +87,26 @@ struct HostListFieldRow: View { } private func addEntry() { - entries.append(HostEntry(host: "", port: String(defaultPort))) + entries.append(HostEntry(value: "")) syncValue() } private func removeEntry(_ entry: HostEntry) { entries.removeAll { $0.id == entry.id } if entries.isEmpty { - entries.append(HostEntry(host: "", port: String(defaultPort))) + entries.append(HostEntry(value: "")) } syncValue() } static func parseHosts(_ value: String, defaultPort: Int) -> [HostEntry] { guard !value.isEmpty else { - return [HostEntry(host: "", port: String(defaultPort))] + return [HostEntry(value: "")] } let parts = value.split(separator: ",", omittingEmptySubsequences: false) - var result: [HostEntry] = [] - for part in parts { - let trimmed = part.trimmingCharacters(in: .whitespaces) - let components = trimmed.split(separator: ":", maxSplits: 1) - let host = components.isEmpty ? "" : String(components[0]) - let port = components.count > 1 ? String(components[1]) : String(defaultPort) - result.append(HostEntry(host: host, port: port)) + let result = parts.map { part in + HostEntry(value: String(part).trimmingCharacters(in: .whitespaces)) } - return result.isEmpty ? [HostEntry(host: "", port: String(defaultPort))] : result + return result.isEmpty ? [HostEntry(value: "")] : result } } From 540afd2011f049dc48d5da3936ab037e59cf7b27 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Apr 2026 23:09:22 +0700 Subject: [PATCH 05/22] fix: native macOS bordered list with +/- buttons for host entries --- .../Views/Connection/HostListFieldRow.swift | 124 +++++++++++++----- 1 file changed, 91 insertions(+), 33 deletions(-) diff --git a/TablePro/Views/Connection/HostListFieldRow.swift b/TablePro/Views/Connection/HostListFieldRow.swift index 5a93f9e2e..f4176d167 100644 --- a/TablePro/Views/Connection/HostListFieldRow.swift +++ b/TablePro/Views/Connection/HostListFieldRow.swift @@ -17,37 +17,21 @@ struct HostListFieldRow: View { @Binding var value: String @State private var entries: [HostEntry] = [] + @State private var selectedId: UUID? var body: some View { LabeledContent { - VStack(alignment: .leading, spacing: 6) { - ForEach($entries) { $entry in - HStack(spacing: 6) { - TextField( - "", - text: $entry.value, - prompt: Text("hostname:\(defaultPort)") - ) - .onChange(of: entry.value) { syncValue() } - - Button { - removeEntry(entry) - } label: { - Image(systemName: "minus.circle.fill") - .foregroundStyle(.red) - } - .buttonStyle(.plain) - .disabled(entries.count <= 1) - .opacity(entries.count <= 1 ? 0.3 : 1) - } - } - - Button { - addEntry() - } label: { - Label("Add Host", systemImage: "plus") - } + VStack(spacing: 0) { + list + Divider() + buttonBar } + .background(Color(nsColor: .controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 5)) + .overlay( + RoundedRectangle(cornerRadius: 5) + .strokeBorder(Color(nsColor: .separatorColor), lineWidth: 0.5) + ) } label: { Text(label) } @@ -55,10 +39,83 @@ struct HostListFieldRow: View { .onChange(of: value) { parseValue() } } + private var list: some View { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(Array(entries.enumerated()), id: \.element.id) { index, entry in + entryRow(entry, index: index) + } + } + } + .frame(minHeight: 28, maxHeight: 88) + } + + private func entryRow(_ entry: HostEntry, index: Int) -> some View { + let isSelected = selectedId == entry.id + return VStack(spacing: 0) { + if index > 0 { + Divider().padding(.horizontal, 1) + } + TextField( + "", + text: bindingForEntry(entry), + prompt: Text("hostname:\(defaultPort)") + ) + .textFieldStyle(.plain) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(isSelected ? Color.accentColor.opacity(0.2) : Color.clear) + .contentShape(Rectangle()) + .onTapGesture { selectedId = entry.id } + } + } + + 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 var buttonBar: some View { + HStack(spacing: 0) { + Button { addEntry() } label: { + Image(systemName: "plus") + .font(.system(size: 11, weight: .medium)) + .frame(width: 24, height: 20) + } + .buttonStyle(.borderless) + + Divider().frame(height: 14) + + Button { removeSelected() } label: { + Image(systemName: "minus") + .font(.system(size: 11, weight: .medium)) + .frame(width: 24, height: 20) + } + .buttonStyle(.borderless) + .disabled(selectedId == nil || entries.count <= 1) + + Spacer() + } + .padding(.horizontal, 2) + .padding(.vertical, 2) + } + private func parseValue() { let parsed = Self.parseHosts(value, defaultPort: defaultPort) if !entriesMatch(parsed) { entries = parsed + if selectedId == nil, let first = parsed.first { + selectedId = first.id + } } } @@ -87,15 +144,16 @@ struct HostListFieldRow: View { } private func addEntry() { - entries.append(HostEntry(value: "")) + let newEntry = HostEntry(value: "") + entries.append(newEntry) + selectedId = newEntry.id syncValue() } - private func removeEntry(_ entry: HostEntry) { - entries.removeAll { $0.id == entry.id } - if entries.isEmpty { - entries.append(HostEntry(value: "")) - } + private func removeSelected() { + guard let id = selectedId, entries.count > 1 else { return } + entries.removeAll { $0.id == id } + selectedId = entries.last?.id syncValue() } From 10f4f918618424a2323625f7b0e04bb7c8b57888 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Apr 2026 23:13:02 +0700 Subject: [PATCH 06/22] fix: use native SwiftUI List with .bordered style for host entries --- .../Views/Connection/HostListFieldRow.swift | 118 +++++++----------- 1 file changed, 46 insertions(+), 72 deletions(-) diff --git a/TablePro/Views/Connection/HostListFieldRow.swift b/TablePro/Views/Connection/HostListFieldRow.swift index f4176d167..fd1bac667 100644 --- a/TablePro/Views/Connection/HostListFieldRow.swift +++ b/TablePro/Views/Connection/HostListFieldRow.swift @@ -17,21 +17,44 @@ struct HostListFieldRow: View { @Binding var value: String @State private var entries: [HostEntry] = [] - @State private var selectedId: UUID? + @State private var selectedId: Set = [] var body: some View { LabeledContent { - VStack(spacing: 0) { - list - Divider() - buttonBar + List(selection: $selectedId) { + ForEach(Array(entries.enumerated()), id: \.element.id) { index, 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) } - .background(Color(nsColor: .controlBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 5)) - .overlay( - RoundedRectangle(cornerRadius: 5) - .strokeBorder(Color(nsColor: .separatorColor), lineWidth: 0.5) - ) } label: { Text(label) } @@ -39,35 +62,11 @@ struct HostListFieldRow: View { .onChange(of: value) { parseValue() } } - private var list: some View { - ScrollView { - LazyVStack(spacing: 0) { - ForEach(Array(entries.enumerated()), id: \.element.id) { index, entry in - entryRow(entry, index: index) - } - } - } - .frame(minHeight: 28, maxHeight: 88) - } - - private func entryRow(_ entry: HostEntry, index: Int) -> some View { - let isSelected = selectedId == entry.id - return VStack(spacing: 0) { - if index > 0 { - Divider().padding(.horizontal, 1) - } - TextField( - "", - text: bindingForEntry(entry), - prompt: Text("hostname:\(defaultPort)") - ) - .textFieldStyle(.plain) - .padding(.horizontal, 8) - .padding(.vertical, 5) - .background(isSelected ? Color.accentColor.opacity(0.2) : Color.clear) - .contentShape(Rectangle()) - .onTapGesture { selectedId = entry.id } - } + 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 { @@ -84,38 +83,10 @@ struct HostListFieldRow: View { ) } - private var buttonBar: some View { - HStack(spacing: 0) { - Button { addEntry() } label: { - Image(systemName: "plus") - .font(.system(size: 11, weight: .medium)) - .frame(width: 24, height: 20) - } - .buttonStyle(.borderless) - - Divider().frame(height: 14) - - Button { removeSelected() } label: { - Image(systemName: "minus") - .font(.system(size: 11, weight: .medium)) - .frame(width: 24, height: 20) - } - .buttonStyle(.borderless) - .disabled(selectedId == nil || entries.count <= 1) - - Spacer() - } - .padding(.horizontal, 2) - .padding(.vertical, 2) - } - private func parseValue() { let parsed = Self.parseHosts(value, defaultPort: defaultPort) if !entriesMatch(parsed) { entries = parsed - if selectedId == nil, let first = parsed.first { - selectedId = first.id - } } } @@ -146,14 +117,17 @@ struct HostListFieldRow: View { private func addEntry() { let newEntry = HostEntry(value: "") entries.append(newEntry) - selectedId = newEntry.id + selectedId = [newEntry.id] syncValue() } private func removeSelected() { - guard let id = selectedId, entries.count > 1 else { return } - entries.removeAll { $0.id == id } - selectedId = entries.last?.id + guard !selectedId.isEmpty, entries.count > 1 else { return } + entries.removeAll { selectedId.contains($0.id) } + if entries.isEmpty { + entries.append(HostEntry(value: "")) + } + selectedId = [] syncValue() } From 99ed1f6184f6c47a238cfa8b5669c93621f83e02 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Apr 2026 23:19:55 +0700 Subject: [PATCH 07/22] =?UTF-8?q?fix:=20address=20review=20findings=20?= =?UTF-8?q?=E2=80=94=20sync=20loop,=20empty=20segments,=20display=20consis?= =?UTF-8?q?tency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MongoDBConnection.swift | 8 ++++--- .../Models/Connection/ConnectionExport.swift | 3 +-- .../Connection/DatabaseConnection.swift | 3 +-- .../ConnectionFormView+GeneralTab.swift | 12 +++++++--- .../ConnectionFormView+Helpers.swift | 24 ++++++++++++++++--- .../Views/Connection/HostListFieldRow.swift | 9 +------ 6 files changed, 38 insertions(+), 21 deletions(-) diff --git a/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift b/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift index 53099638f..ad74d85bc 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift @@ -181,16 +181,18 @@ final class MongoDBConnection: @unchecked Sendable { let encodedHost = host.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? host uri += encodedHost } else if host.contains(",") { - let segments = host.split(separator: ",").map { segment -> String in + let segments = host.split(separator: ",").compactMap { segment -> String? in let parts = segment.split(separator: ":", maxSplits: 1) - let h = String(parts[0]).trimmingCharacters(in: .whitespaces) + 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.joined(separator: ",") + uri += segments.isEmpty ? "localhost:\(port)" : segments.joined(separator: ",") } else { let encodedHost = host.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? host uri += "\(encodedHost):\(port)" diff --git a/TablePro/Models/Connection/ConnectionExport.swift b/TablePro/Models/Connection/ConnectionExport.swift index ecaa3c210..052b9a5a6 100644 --- a/TablePro/Models/Connection/ConnectionExport.swift +++ b/TablePro/Models/Connection/ConnectionExport.swift @@ -62,8 +62,7 @@ struct ExportableConnection: Codable { var hostDisplayString: String { if let mongoHosts = additionalFields?["mongoHosts"], mongoHosts.contains(",") { let count = mongoHosts.split(separator: ",").count - let firstHost = mongoHosts.split(separator: ",").first.map(String.init) ?? host - return String(format: String(localized: "%@ (+%d more)"), firstHost, count - 1) + return String(format: String(localized: "%@ (+%d more)"), "\(host):\(port)", count - 1) } return "\(host):\(port)" } diff --git a/TablePro/Models/Connection/DatabaseConnection.swift b/TablePro/Models/Connection/DatabaseConnection.swift index 77c6c9eea..7b47fb7cc 100644 --- a/TablePro/Models/Connection/DatabaseConnection.swift +++ b/TablePro/Models/Connection/DatabaseConnection.swift @@ -373,8 +373,7 @@ extension DatabaseConnection { var hostDisplayString: String { if let mongoHosts = additionalFields["mongoHosts"], mongoHosts.contains(",") { let count = mongoHosts.split(separator: ",").count - let firstHost = mongoHosts.split(separator: ",").first.map(String.init) ?? host - return String(format: String(localized: "%@ (+%d more)"), firstHost, count - 1) + return String(format: String(localized: "%@ (+%d more)"), "\(host):\(port)", count - 1) } return "\(host):\(port)" } diff --git a/TablePro/Views/Connection/ConnectionFormView+GeneralTab.swift b/TablePro/Views/Connection/ConnectionFormView+GeneralTab.swift index 749682f70..84b35cda9 100644 --- a/TablePro/Views/Connection/ConnectionFormView+GeneralTab.swift +++ b/TablePro/Views/Connection/ConnectionFormView+GeneralTab.swift @@ -111,8 +111,10 @@ extension ConnectionFormView { if hasHostListField { ForEach(connectionSectionFields, id: \.id) { field in if case .hostList = field.fieldType { - ConnectionFieldRow( - field: field, + HostListFieldRow( + label: field.label, + placeholder: field.placeholder, + defaultPort: type.defaultPort, value: Binding( get: { additionalFieldValues[field.id] @@ -145,7 +147,11 @@ extension ConnectionFormView { } if sshState.enabled && hasHostListField { - let hostsValue = additionalFieldValues["mongoHosts"] ?? "" + 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( diff --git a/TablePro/Views/Connection/ConnectionFormView+Helpers.swift b/TablePro/Views/Connection/ConnectionFormView+Helpers.swift index 9afed0a7c..dfc2d81fa 100644 --- a/TablePro/Views/Connection/ConnectionFormView+Helpers.swift +++ b/TablePro/Views/Connection/ConnectionFormView+Helpers.swift @@ -189,12 +189,21 @@ extension ConnectionFormView { var finalAdditionalFields = additionalFieldValues - // Derive primary host/port from mongoHosts for display and storage + // Normalize and derive primary host/port from mongoHosts if type.pluginTypeId == "MongoDB", let mongoHosts = finalAdditionalFields["mongoHosts"], !mongoHosts.isEmpty { - let firstSegment = mongoHosts.split(separator: ",").first.map(String.init) ?? mongoHosts + let normalized = mongoHosts.split(separator: ",", omittingEmptySubsequences: false) + .map { segment -> String in + let trimmed = segment.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { return "localhost:\(type.defaultPort)" } + if !trimmed.contains(":") { return "\(trimmed):\(type.defaultPort)" } + return trimmed + } + .joined(separator: ",") + finalAdditionalFields["mongoHosts"] = normalized + let firstSegment = normalized.split(separator: ",").first.map(String.init) ?? normalized let parts = firstSegment.split(separator: ":", maxSplits: 1) if !parts.isEmpty { let derived = String(parts[0]).trimmingCharacters(in: .whitespaces) @@ -404,7 +413,16 @@ extension ConnectionFormView { let mongoHosts = finalAdditionalFields["mongoHosts"], !mongoHosts.isEmpty { - let firstSegment = mongoHosts.split(separator: ",").first.map(String.init) ?? mongoHosts + let normalized = mongoHosts.split(separator: ",", omittingEmptySubsequences: false) + .map { segment -> String in + let trimmed = segment.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { return "localhost:\(type.defaultPort)" } + if !trimmed.contains(":") { return "\(trimmed):\(type.defaultPort)" } + return trimmed + } + .joined(separator: ",") + finalAdditionalFields["mongoHosts"] = normalized + let firstSegment = normalized.split(separator: ",").first.map(String.init) ?? normalized let parts = firstSegment.split(separator: ":", maxSplits: 1) if !parts.isEmpty { let derived = String(parts[0]).trimmingCharacters(in: .whitespaces) diff --git a/TablePro/Views/Connection/HostListFieldRow.swift b/TablePro/Views/Connection/HostListFieldRow.swift index fd1bac667..fb7e3e3a1 100644 --- a/TablePro/Views/Connection/HostListFieldRow.swift +++ b/TablePro/Views/Connection/HostListFieldRow.swift @@ -100,14 +100,7 @@ struct HostListFieldRow: View { private func syncValue() { let result = entries.map { entry -> String in - let trimmed = entry.value.trimmingCharacters(in: .whitespaces) - if trimmed.isEmpty { - return "localhost:\(defaultPort)" - } - if !trimmed.contains(":") { - return "\(trimmed):\(defaultPort)" - } - return trimmed + entry.value.trimmingCharacters(in: .whitespaces) }.joined(separator: ",") if value != result { value = result From a854e5facaa3b913c78d1f0fe4543dbc6773cf54 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Apr 2026 23:21:29 +0700 Subject: [PATCH 08/22] fix: URL import preview shows all hosts for multi-host MongoDB URIs --- TablePro/Views/Connection/ConnectionFormView+Footer.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/TablePro/Views/Connection/ConnectionFormView+Footer.swift b/TablePro/Views/Connection/ConnectionFormView+Footer.swift index 0b73bd60b..7a9a1e836 100644 --- a/TablePro/Views/Connection/ConnectionFormView+Footer.swift +++ b/TablePro/Views/Connection/ConnectionFormView+Footer.swift @@ -199,7 +199,11 @@ extension ConnectionFormView { previewRow(String(localized: "Host"), parsed.host) } case .network: - if !parsed.host.isEmpty { + if let multiHost = parsed.multiHost, multiHost.contains(",") { + let hosts = multiHost.split(separator: ",") + let display = hosts.map(String.init).joined(separator: "\n") + previewRow(String(localized: "Hosts"), display) + } else if !parsed.host.isEmpty { let portStr = parsed.port.map { ":\($0)" } ?? "" previewRow(String(localized: "Host"), parsed.host + portStr) } From b79f83fc20d15eb93a1e77de658ac7fbd4fd3028 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Apr 2026 23:23:31 +0700 Subject: [PATCH 09/22] fix: show comma-separated hosts in URL import preview --- TablePro/Views/Connection/ConnectionFormView+Footer.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/TablePro/Views/Connection/ConnectionFormView+Footer.swift b/TablePro/Views/Connection/ConnectionFormView+Footer.swift index 7a9a1e836..d4894a755 100644 --- a/TablePro/Views/Connection/ConnectionFormView+Footer.swift +++ b/TablePro/Views/Connection/ConnectionFormView+Footer.swift @@ -200,9 +200,7 @@ extension ConnectionFormView { } case .network: if let multiHost = parsed.multiHost, multiHost.contains(",") { - let hosts = multiHost.split(separator: ",") - let display = hosts.map(String.init).joined(separator: "\n") - previewRow(String(localized: "Hosts"), display) + previewRow(String(localized: "Hosts"), multiHost) } else if !parsed.host.isEmpty { let portStr = parsed.port.map { ":\($0)" } ?? "" previewRow(String(localized: "Host"), parsed.host + portStr) From 8c7b4d59b75fe9a2b459a1af46ab616f429af191 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Apr 2026 23:25:45 +0700 Subject: [PATCH 10/22] fix: force section rebuild on type change to prevent retain crash --- TablePro/Views/Connection/ConnectionFormView+GeneralTab.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/TablePro/Views/Connection/ConnectionFormView+GeneralTab.swift b/TablePro/Views/Connection/ConnectionFormView+GeneralTab.swift index 84b35cda9..205817032 100644 --- a/TablePro/Views/Connection/ConnectionFormView+GeneralTab.swift +++ b/TablePro/Views/Connection/ConnectionFormView+GeneralTab.swift @@ -145,6 +145,7 @@ extension ConnectionFormView { ) } } + .id(type) if sshState.enabled && hasHostListField { let hostListFieldId = connectionSectionFields.first { From ea00eb896542c36fb18d46f69f098d39daeb6d9b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Apr 2026 23:28:03 +0700 Subject: [PATCH 11/22] fix: apply .id(type) to entire form to prevent retain crash on type switch --- TablePro/Views/Connection/ConnectionFieldRow.swift | 7 +------ .../Views/Connection/ConnectionFormView+GeneralTab.swift | 2 +- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/TablePro/Views/Connection/ConnectionFieldRow.swift b/TablePro/Views/Connection/ConnectionFieldRow.swift index e5fdf13e7..61b81a5ce 100644 --- a/TablePro/Views/Connection/ConnectionFieldRow.swift +++ b/TablePro/Views/Connection/ConnectionFieldRow.swift @@ -62,12 +62,7 @@ struct ConnectionFieldRow: View { Text("\(field.label): \(Int(value) ?? range.lowerBound)") } case .hostList: - HostListFieldRow( - label: field.label, - placeholder: field.placeholder, - defaultPort: Int(field.defaultValue ?? "27017") ?? 27_017, - value: $value - ) + EmptyView() } } } diff --git a/TablePro/Views/Connection/ConnectionFormView+GeneralTab.swift b/TablePro/Views/Connection/ConnectionFormView+GeneralTab.swift index 205817032..87a595b59 100644 --- a/TablePro/Views/Connection/ConnectionFormView+GeneralTab.swift +++ b/TablePro/Views/Connection/ConnectionFormView+GeneralTab.swift @@ -145,7 +145,6 @@ extension ConnectionFormView { ) } } - .id(type) if sshState.enabled && hasHostListField { let hostListFieldId = connectionSectionFields.first { @@ -244,6 +243,7 @@ extension ConnectionFormView { } } } + .id(type) .formStyle(.grouped) .scrollContentBackground(.hidden) .sheet(isPresented: $showURLImport) { From 5f58e85255d4106d3d71b456c9d9c26b0014d4bf Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Apr 2026 23:32:37 +0700 Subject: [PATCH 12/22] fix: extract hostFieldsView to reduce form body type complexity --- .../ConnectionFormView+GeneralTab.swift | 64 ++++++++++--------- 1 file changed, 34 insertions(+), 30 deletions(-) diff --git a/TablePro/Views/Connection/ConnectionFormView+GeneralTab.swift b/TablePro/Views/Connection/ConnectionFormView+GeneralTab.swift index 87a595b59..c7b00c446 100644 --- a/TablePro/Views/Connection/ConnectionFormView+GeneralTab.swift +++ b/TablePro/Views/Connection/ConnectionFormView+GeneralTab.swift @@ -108,35 +108,7 @@ extension ConnectionFormView { } } else { Section(String(localized: "Connection")) { - 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) - ) - } + hostFieldsView if PluginManager.shared.requiresAuthentication(for: type) { TextField( String(localized: "Database"), @@ -243,7 +215,6 @@ extension ConnectionFormView { } } } - .id(type) .formStyle(.grouped) .scrollContentBackground(.hidden) .sheet(isPresented: $showURLImport) { @@ -253,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) + ) + } + } } From c25f601a02d6acd7e67b07f41bbaacf8771cb4d7 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Apr 2026 23:39:54 +0700 Subject: [PATCH 13/22] fix: remove unused index, extract normalization helper, add multi-host URL tests --- .../ConnectionFormView+Helpers.swift | 78 ++++++++++--------- .../Views/Connection/HostListFieldRow.swift | 2 +- .../Utilities/DatabaseURLSchemeTests.swift | 50 ++++++++++++ 3 files changed, 92 insertions(+), 38 deletions(-) diff --git a/TablePro/Views/Connection/ConnectionFormView+Helpers.swift b/TablePro/Views/Connection/ConnectionFormView+Helpers.swift index dfc2d81fa..5a0268bce 100644 --- a/TablePro/Views/Connection/ConnectionFormView+Helpers.swift +++ b/TablePro/Views/Connection/ConnectionFormView+Helpers.swift @@ -189,29 +189,14 @@ extension ConnectionFormView { var finalAdditionalFields = additionalFieldValues - // Normalize and derive primary host/port from mongoHosts if type.pluginTypeId == "MongoDB", let mongoHosts = finalAdditionalFields["mongoHosts"], !mongoHosts.isEmpty { - let normalized = mongoHosts.split(separator: ",", omittingEmptySubsequences: false) - .map { segment -> String in - let trimmed = segment.trimmingCharacters(in: .whitespaces) - if trimmed.isEmpty { return "localhost:\(type.defaultPort)" } - if !trimmed.contains(":") { return "\(trimmed):\(type.defaultPort)" } - return trimmed - } - .joined(separator: ",") - finalAdditionalFields["mongoHosts"] = normalized - let firstSegment = normalized.split(separator: ",").first.map(String.init) ?? normalized - let parts = firstSegment.split(separator: ":", maxSplits: 1) - if !parts.isEmpty { - let derived = String(parts[0]).trimmingCharacters(in: .whitespaces) - finalHost = derived.isEmpty ? "localhost" : derived - } - if parts.count > 1, let p = Int(parts[1].trimmingCharacters(in: .whitespaces)) { - finalPort = p - } + 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 { @@ -413,24 +398,10 @@ extension ConnectionFormView { let mongoHosts = finalAdditionalFields["mongoHosts"], !mongoHosts.isEmpty { - let normalized = mongoHosts.split(separator: ",", omittingEmptySubsequences: false) - .map { segment -> String in - let trimmed = segment.trimmingCharacters(in: .whitespaces) - if trimmed.isEmpty { return "localhost:\(type.defaultPort)" } - if !trimmed.contains(":") { return "\(trimmed):\(type.defaultPort)" } - return trimmed - } - .joined(separator: ",") - finalAdditionalFields["mongoHosts"] = normalized - let firstSegment = normalized.split(separator: ",").first.map(String.init) ?? normalized - let parts = firstSegment.split(separator: ":", maxSplits: 1) - if !parts.isEmpty { - let derived = String(parts[0]).trimmingCharacters(in: .whitespaces) - testHost = derived.isEmpty ? "localhost" : derived - } - if parts.count > 1, let p = Int(parts[1].trimmingCharacters(in: .whitespaces)) { - testPort = p - } + let result = Self.normalizeMongoHosts(mongoHosts, defaultPort: type.defaultPort) + finalAdditionalFields["mongoHosts"] = result.hosts + testHost = result.primaryHost + testPort = result.primaryPort } let testTunnelMode = sshState.buildTunnelMode() @@ -667,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/HostListFieldRow.swift b/TablePro/Views/Connection/HostListFieldRow.swift index fb7e3e3a1..a2b752bbf 100644 --- a/TablePro/Views/Connection/HostListFieldRow.swift +++ b/TablePro/Views/Connection/HostListFieldRow.swift @@ -22,7 +22,7 @@ struct HostListFieldRow: View { var body: some View { LabeledContent { List(selection: $selectedId) { - ForEach(Array(entries.enumerated()), id: \.element.id) { index, entry in + ForEach(entries) { entry in TextField("", text: bindingForEntry(entry), prompt: Text("hostname:\(defaultPort)")) .tag(entry.id) } 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") + } } From 67e086510db7be372ac2737adee10af38277838d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Apr 2026 23:40:27 +0700 Subject: [PATCH 14/22] fix: add scroll support for connection form and group delete confirmation --- CHANGELOG.md | 2 ++ TablePro/ViewModels/WelcomeViewModel.swift | 11 ++++++++++- .../Views/Connection/ConnectionFormView.swift | 2 +- .../Views/Connection/WelcomeWindowView.swift | 17 ++++++++++++++++- 4 files changed, 29 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a2b0d124..d2a6d04be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Connection form can overflow with SSH jump hosts and TOTP fields due to fixed height +- Group deletion has no confirmation dialog - 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/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/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index de8b579fa..c214e8ba2 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -165,7 +165,7 @@ struct ConnectionFormView: View { footer } - .frame(width: 480, height: 520) + .frame(width: 480, minHeight: 520, idealHeight: 520) .navigationTitle( isNew ? String(localized: "New Connection") : String(localized: "Edit Connection") ) diff --git a/TablePro/Views/Connection/WelcomeWindowView.swift b/TablePro/Views/Connection/WelcomeWindowView.swift index 8a5371699..82d13f630 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): @@ -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") } From 7a61463a768737fe0062ff74024397e5e7a6eeb4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Apr 2026 23:40:27 +0700 Subject: [PATCH 15/22] fix: resolve plugin bundle thread-safety race in principalClass access --- CHANGELOG.md | 1 + TablePro/Core/Plugins/PluginManager.swift | 79 +++++++++-------------- 2 files changed, 30 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2a6d04be..54d41b663 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Connection form can overflow with SSH jump hosts and TOTP fields due to fixed height - Group deletion has no confirmation dialog +- Thread-safety race in plugin system when resolving principalClass off the 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/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")]") From 1e7aa1b136324cd363aebd7703db25e08aa73e87 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Apr 2026 23:39:21 +0700 Subject: [PATCH 16/22] fix: resolve 3 critical code issues from codebase audit --- .../Main/Extensions/MainContentCoordinator+Alerts.swift | 5 +++-- TablePro/Views/Main/Extensions/MainContentView+Helpers.swift | 2 +- TablePro/Views/Main/MainContentCoordinator.swift | 5 ++++- 3 files changed, 8 insertions(+), 4 deletions(-) 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) From 73bf6388a61070afb07e37ad322a3df9c6a19b15 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 25 Apr 2026 16:54:58 +0700 Subject: [PATCH 17/22] wip --- TablePro/Resources/Localizable.xcstrings | 46 +++++++++++++++++------- 1 file changed, 34 insertions(+), 12 deletions(-) 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" : { From 30618548443e8c92dfa05e3554a00dd4d804ac5f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 25 Apr 2026 17:08:10 +0700 Subject: [PATCH 18/22] fix: remove unused ExportableConnection.hostDisplayString, align display consistency --- TablePro/Models/Connection/ConnectionExport.swift | 8 -------- TablePro/Views/Connection/WelcomeConnectionRow.swift | 5 ++++- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/TablePro/Models/Connection/ConnectionExport.swift b/TablePro/Models/Connection/ConnectionExport.swift index 052b9a5a6..0b112b43c 100644 --- a/TablePro/Models/Connection/ConnectionExport.swift +++ b/TablePro/Models/Connection/ConnectionExport.swift @@ -58,14 +58,6 @@ struct ExportableConnection: Codable { let redisDatabase: Int? let startupCommands: String? let localOnly: Bool? - - 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: - SSH Config diff --git a/TablePro/Views/Connection/WelcomeConnectionRow.swift b/TablePro/Views/Connection/WelcomeConnectionRow.swift index 2fa4cdea0..715dbcb66 100644 --- a/TablePro/Views/Connection/WelcomeConnectionRow.swift +++ b/TablePro/Views/Connection/WelcomeConnectionRow.swift @@ -78,7 +78,10 @@ struct WelcomeConnectionRow: View { } if let mongoHosts = connection.additionalFields["mongoHosts"], mongoHosts.contains(",") { let count = mongoHosts.split(separator: ",").count - return String(format: String(localized: "%@ (+%d more)"), connection.host, count - 1) + return String( + format: String(localized: "%@ (+%d more)"), + "\(connection.host):\(connection.port)", count - 1 + ) } return connection.host } From 410e51938f885b5f50f224219d561e9e0c22fefa Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 25 Apr 2026 17:10:20 +0700 Subject: [PATCH 19/22] fix: invalid frame modifier on connection form --- TablePro/Views/Connection/ConnectionFormView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index c214e8ba2..de8b579fa 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -165,7 +165,7 @@ struct ConnectionFormView: View { footer } - .frame(width: 480, minHeight: 520, idealHeight: 520) + .frame(width: 480, height: 520) .navigationTitle( isNew ? String(localized: "New Connection") : String(localized: "Edit Connection") ) From f67487f3358db999efe164e389f4e05e799f5efa Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 25 Apr 2026 17:12:43 +0700 Subject: [PATCH 20/22] fix: use inline host:port for ExportableConnection in import preview --- .../Connection/ImportFromApp/ConnectionImportPreviewList.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TablePro/Views/Connection/ImportFromApp/ConnectionImportPreviewList.swift b/TablePro/Views/Connection/ImportFromApp/ConnectionImportPreviewList.swift index b8ba3439e..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.hostDisplayString) + Text("\(item.connection.host):\(item.connection.port)") warningText(for: item.status) } .font(.subheadline) From d985f372fd163704452b6e290012a8bc8201076b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 25 Apr 2026 17:14:50 +0700 Subject: [PATCH 21/22] fix: use inline host:port for ExportableConnection in linked row --- TablePro/Views/Connection/WelcomeWindowView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TablePro/Views/Connection/WelcomeWindowView.swift b/TablePro/Views/Connection/WelcomeWindowView.swift index 82d13f630..1fd5e7c41 100644 --- a/TablePro/Views/Connection/WelcomeWindowView.swift +++ b/TablePro/Views/Connection/WelcomeWindowView.swift @@ -372,7 +372,7 @@ struct WelcomeWindowView: View { VStack(alignment: .leading, spacing: 2) { Text(linked.connection.name) .lineLimit(1) - Text(linked.connection.hostDisplayString) + Text("\(linked.connection.host):\(linked.connection.port)") .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) From ee8fd37c758a5544503e4e7ad23a72b58f063eec Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 25 Apr 2026 17:18:47 +0700 Subject: [PATCH 22/22] docs: simplify changelog entries --- CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e7605501..b59ddd132 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- MongoDB replica set support with multi-host connections +- 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 @@ -28,9 +28,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Connection form can overflow with SSH jump hosts and TOTP fields due to fixed height -- Group deletion has no confirmation dialog -- Thread-safety race in plugin system when resolving principalClass off the main thread +- 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