Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
a63f8de
feat: add MongoDB replica set multi-host connection support
datlechin Apr 24, 2026
9193cf0
merge: resolve CHANGELOG conflict with main
datlechin Apr 24, 2026
cc1927f
docs: trim MongoDB replica set section
datlechin Apr 24, 2026
9ce2418
fix: native macOS layout for multi-host field
datlechin Apr 24, 2026
8d1e921
fix: single host:port field per row for cleaner form layout
datlechin Apr 24, 2026
540afd2
fix: native macOS bordered list with +/- buttons for host entries
datlechin Apr 24, 2026
10f4f91
fix: use native SwiftUI List with .bordered style for host entries
datlechin Apr 24, 2026
99ed1f6
fix: address review findings — sync loop, empty segments, display con…
datlechin Apr 24, 2026
a854e5f
fix: URL import preview shows all hosts for multi-host MongoDB URIs
datlechin Apr 24, 2026
b79f83f
fix: show comma-separated hosts in URL import preview
datlechin Apr 24, 2026
8c7b4d5
fix: force section rebuild on type change to prevent retain crash
datlechin Apr 24, 2026
ea00eb8
fix: apply .id(type) to entire form to prevent retain crash on type s…
datlechin Apr 24, 2026
5f58e85
fix: extract hostFieldsView to reduce form body type complexity
datlechin Apr 24, 2026
c25f601
fix: remove unused index, extract normalization helper, add multi-hos…
datlechin Apr 24, 2026
67e0865
fix: add scroll support for connection form and group delete confirma…
datlechin Apr 24, 2026
7a61463
fix: resolve plugin bundle thread-safety race in principalClass access
datlechin Apr 24, 2026
1e7aa1b
fix: resolve 3 critical code issues from codebase audit
datlechin Apr 24, 2026
04ce6e0
Merge branch 'main' into feat/mongodb-multi-host
datlechin Apr 25, 2026
73bf638
wip
datlechin Apr 25, 2026
3061854
fix: remove unused ExportableConnection.hostDisplayString, align disp…
datlechin Apr 25, 2026
410e519
fix: invalid frame modifier on connection form
datlechin Apr 25, 2026
f67487f
fix: use inline host:port for ExportableConnection in import preview
datlechin Apr 25, 2026
d985f37
fix: use inline host:port for ExportableConnection in linked row
datlechin Apr 25, 2026
ee8fd37
docs: simplify changelog entries
datlechin Apr 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
20 changes: 17 additions & 3 deletions Plugins/MongoDBDriverPlugin/MongoDBConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions Plugins/MongoDBDriverPlugin/MongoDBPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions Plugins/TableProPluginKit/ConnectionField.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Foundation
public enum FieldSection: String, Codable, Sendable {
case authentication
case advanced
case connection
}

public struct FieldVisibilityRule: Codable, Sendable, Equatable {
Expand Down Expand Up @@ -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 {
Expand Down
79 changes: 29 additions & 50 deletions TablePro/Core/Plugins/PluginManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
Expand Down Expand Up @@ -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")]")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
),
Expand Down
121 changes: 115 additions & 6 deletions TablePro/Core/Utilities/Connection/ConnectionURLParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -162,14 +164,23 @@ struct ConnectionURLParser {
filterCondition: nil,
oracleServiceName: nil,
useSrv: false,
mongoQueryParams: [:]
mongoQueryParams: [:],
multiHost: nil
))
}

if isSSH {
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)
Expand Down Expand Up @@ -256,7 +267,8 @@ struct ConnectionURLParser {
filterCondition: ext.filterCondition,
oracleServiceName: oracleServiceName,
useSrv: ext.useSrv,
mongoQueryParams: ext.mongoQueryParams
mongoQueryParams: ext.mongoQueryParams,
multiHost: nil
))
}

Expand Down Expand Up @@ -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..<questionIndex])
queryString = String(afterScheme[afterScheme.index(after: questionIndex)...])
}

var authority = mainPart
var database = ""
if let slashIndex = mainPart.firstIndex(of: "/") {
authority = String(mainPart[mainPart.startIndex..<slashIndex])
database = String(mainPart[mainPart.index(after: slashIndex)...])
.removingPercentEncoding ?? String(mainPart[mainPart.index(after: slashIndex)...])
}

var credentials = ""
var hostPortion = authority
if let atIndex = authority.lastIndex(of: "@") {
credentials = String(authority[authority.startIndex..<atIndex])
hostPortion = String(authority[authority.index(after: atIndex)...])
}

guard hostPortion.contains(",") else { return nil }

var username = ""
var password = ""
if !credentials.isEmpty {
if let colonIndex = credentials.firstIndex(of: ":") {
username = String(credentials[credentials.startIndex..<colonIndex])
.removingPercentEncoding ?? ""
password = String(credentials[credentials.index(after: colonIndex)...])
.removingPercentEncoding ?? ""
} else {
username = credentials.removingPercentEncoding ?? ""
}
}

let multiHost = hostPortion

let firstSegment = hostPortion.split(separator: ",").first.map(String.init) ?? hostPortion
let hostParts = firstSegment.split(separator: ":", maxSplits: 1)
let firstHost = String(hostParts[0]).removingPercentEncoding ?? String(hostParts[0])
let firstPort: Int? = hostParts.count > 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 {
Expand Down
Loading
Loading