From 9b76a315fd935d27005fd02dcdf942d69fd3974e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 22 Mar 2026 17:25:03 +0700 Subject: [PATCH 1/8] feat: show all database types in connection form with install status badge (#418) --- CHANGELOG.md | 1 + TablePro/Core/Plugins/PluginManager.swift | 8 ++++++++ TablePro/Views/Connection/ConnectionFormView.swift | 9 ++++++--- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dec4026f..793aa36d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Keyboard focus navigation (Tab, Ctrl+J/K/N/P, arrow keys) for connection list, quick switcher, and database switcher - MongoDB `mongodb+srv://` URI support with SRV toggle, Auth Mechanism dropdown, and Replica Set field (#419) +- Show all available database types in connection form with install status badge (#418) ### Changed diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index d058d691..20a97c9f 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -647,6 +647,14 @@ final class PluginManager { return types.sorted { $0.rawValue < $1.rawValue } } + var allAvailableDatabaseTypes: [DatabaseType] { + var types = Set(availableDatabaseTypes) + for type in DatabaseType.allKnownTypes { + types.insert(type) + } + return types.sorted { $0.rawValue < $1.rawValue } + } + // MARK: - Driver Availability func isDriverAvailable(for databaseType: DatabaseType) -> Bool { diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index 56e49508..1b4f4215 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -27,7 +27,7 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length private var isNew: Bool { connectionId == nil } private var availableDatabaseTypes: [DatabaseType] { - PluginManager.shared.availableDatabaseTypes + PluginManager.shared.allAvailableDatabaseTypes } private var additionalConnectionFields: [ConnectionField] { @@ -237,9 +237,12 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length HStack { Text(t.rawValue) if t.isDownloadablePlugin && !PluginManager.shared.isDriverLoaded(for: t) { - Image(systemName: "arrow.down.circle") - .foregroundStyle(.secondary) + Text("Not Installed") .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background(.quaternary, in: RoundedRectangle(cornerRadius: 3)) } } } icon: { From 8c6e243b5db18ae61e85b021c954f31ce7277453 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 22 Mar 2026 17:31:00 +0700 Subject: [PATCH 2/8] feat: show install prompt when selecting uninstalled database type --- .../Views/Connection/ConnectionFormView.swift | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index 1b4f4215..f73a9a9e 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -266,7 +266,21 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length } } - if PluginManager.shared.connectionMode(for: type) == .fileBased { + if type.isDownloadablePlugin && !PluginManager.shared.isDriverLoaded(for: type) { + Section { + VStack(spacing: 8) { + Text(String(localized: "The \(type.rawValue) plugin is not installed.")) + .foregroundStyle(.secondary) + Button(String(localized: "Install Plugin")) { + installPlugin(for: type) + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + } + } else if PluginManager.shared.connectionMode(for: type) == .fileBased { Section(String(localized: "Database File")) { HStack { TextField( @@ -1457,6 +1471,12 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length } } + private func installPlugin(for databaseType: DatabaseType) { + Task { + try? await PluginManager.shared.installMissingPlugin(for: databaseType) { _ in } + } + } + private func cleanupTestSecrets(for testId: UUID) { ConnectionStorage.shared.deletePassword(for: testId) ConnectionStorage.shared.deleteSSHPassword(for: testId) From 4e30631c0774a68cb9bd1ab8dd789e229c5e1d81 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 22 Mar 2026 17:33:58 +0700 Subject: [PATCH 3/8] feat: add loading spinner during plugin install in connection form --- .../Views/Connection/ConnectionFormView.swift | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index f73a9a9e..2e916edf 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -126,6 +126,7 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length @State private var testSucceeded: Bool = false @State private var pluginInstallConnection: DatabaseConnection? + @State private var isInstallingPlugin: Bool = false // Tab selection @State private var selectedTab: FormTab = .general @@ -269,13 +270,20 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length if type.isDownloadablePlugin && !PluginManager.shared.isDriverLoaded(for: type) { Section { VStack(spacing: 8) { - Text(String(localized: "The \(type.rawValue) plugin is not installed.")) - .foregroundStyle(.secondary) - Button(String(localized: "Install Plugin")) { - installPlugin(for: type) + if isInstallingPlugin { + ProgressView() + .controlSize(.small) + Text(String(localized: "Installing \(type.rawValue) plugin…")) + .foregroundStyle(.secondary) + } else { + Text(String(localized: "The \(type.rawValue) plugin is not installed.")) + .foregroundStyle(.secondary) + Button(String(localized: "Install Plugin")) { + installPlugin(for: type) + } + .buttonStyle(.borderedProminent) + .controlSize(.small) } - .buttonStyle(.borderedProminent) - .controlSize(.small) } .frame(maxWidth: .infinity) .padding(.vertical, 8) @@ -1472,7 +1480,9 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length } private func installPlugin(for databaseType: DatabaseType) { + isInstallingPlugin = true Task { + defer { isInstallingPlugin = false } try? await PluginManager.shared.installMissingPlugin(for: databaseType) { _ in } } } From b06c12687d020094cb1c9cfec82b7c6e176e78e7 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 22 Mar 2026 17:37:27 +0700 Subject: [PATCH 4/8] fix: show error message when plugin install fails and add retry button --- .../Views/Connection/ConnectionFormView.swift | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index 2e916edf..611d0e32 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -127,6 +127,7 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length @State private var pluginInstallConnection: DatabaseConnection? @State private var isInstallingPlugin: Bool = false + @State private var pluginInstallError: String? // Tab selection @State private var selectedTab: FormTab = .general @@ -275,6 +276,16 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length .controlSize(.small) Text(String(localized: "Installing \(type.rawValue) plugin…")) .foregroundStyle(.secondary) + } else if let error = pluginInstallError { + Text(error) + .foregroundStyle(.red) + .font(.caption) + Button(String(localized: "Retry")) { + pluginInstallError = nil + installPlugin(for: type) + } + .buttonStyle(.borderedProminent) + .controlSize(.small) } else { Text(String(localized: "The \(type.rawValue) plugin is not installed.")) .foregroundStyle(.secondary) @@ -1482,8 +1493,12 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length private func installPlugin(for databaseType: DatabaseType) { isInstallingPlugin = true Task { - defer { isInstallingPlugin = false } - try? await PluginManager.shared.installMissingPlugin(for: databaseType) { _ in } + do { + try await PluginManager.shared.installMissingPlugin(for: databaseType) { _ in } + } catch { + pluginInstallError = error.localizedDescription + } + isInstallingPlugin = false } } From 3d6496a66caa4b7376a419a60386a179e497fae1 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 22 Mar 2026 17:40:02 +0700 Subject: [PATCH 5/8] refactor: use ContentUnavailableView for uninstalled plugin state in connection form --- .../Views/Connection/ConnectionFormView.swift | 48 +++++++++---------- 1 file changed, 22 insertions(+), 26 deletions(-) diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index 611d0e32..e404e5b2 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -269,35 +269,31 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length } if type.isDownloadablePlugin && !PluginManager.shared.isDriverLoaded(for: type) { - Section { - VStack(spacing: 8) { - if isInstallingPlugin { - ProgressView() - .controlSize(.small) - Text(String(localized: "Installing \(type.rawValue) plugin…")) - .foregroundStyle(.secondary) - } else if let error = pluginInstallError { - Text(error) - .foregroundStyle(.red) - .font(.caption) - Button(String(localized: "Retry")) { - pluginInstallError = nil - installPlugin(for: type) - } - .buttonStyle(.borderedProminent) - .controlSize(.small) - } else { - Text(String(localized: "The \(type.rawValue) plugin is not installed.")) - .foregroundStyle(.secondary) - Button(String(localized: "Install Plugin")) { - installPlugin(for: type) - } - .buttonStyle(.borderedProminent) + ContentUnavailableView { + Label(type.rawValue, image: type.iconName) + } description: { + if isInstallingPlugin { + Text("Installing plugin…") + } else if let error = pluginInstallError { + Text(error) + } else { + Text("This plugin is not installed yet.") + } + } actions: { + if isInstallingPlugin { + ProgressView() .controlSize(.small) + } else if pluginInstallError != nil { + Button("Retry") { + pluginInstallError = nil + installPlugin(for: type) + } + } else { + Button("Install Plugin") { + installPlugin(for: type) } + .buttonStyle(.borderedProminent) } - .frame(maxWidth: .infinity) - .padding(.vertical, 8) } } else if PluginManager.shared.connectionMode(for: type) == .fileBased { Section(String(localized: "Database File")) { From 13c0285f1e5edeedd6934e7eee4041a81238f2f7 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 22 Mar 2026 17:41:30 +0700 Subject: [PATCH 6/8] refactor: use inline LabeledContent for plugin install state in connection form --- .../Views/Connection/ConnectionFormView.swift | 53 +++++++++++-------- 1 file changed, 30 insertions(+), 23 deletions(-) diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index e404e5b2..9ad9f126 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -269,30 +269,37 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length } if type.isDownloadablePlugin && !PluginManager.shared.isDriverLoaded(for: type) { - ContentUnavailableView { - Label(type.rawValue, image: type.iconName) - } description: { - if isInstallingPlugin { - Text("Installing plugin…") - } else if let error = pluginInstallError { - Text(error) - } else { - Text("This plugin is not installed yet.") - } - } actions: { - if isInstallingPlugin { - ProgressView() - .controlSize(.small) - } else if pluginInstallError != nil { - Button("Retry") { - pluginInstallError = nil - installPlugin(for: type) - } - } else { - Button("Install Plugin") { - installPlugin(for: type) + Section { + LabeledContent(String(localized: "Plugin")) { + if isInstallingPlugin { + HStack(spacing: 6) { + ProgressView() + .controlSize(.small) + Text("Installing…") + .foregroundStyle(.secondary) + } + } else if let error = pluginInstallError { + HStack(spacing: 6) { + Text(error) + .foregroundStyle(.red) + .font(.caption) + .lineLimit(2) + Button("Retry") { + pluginInstallError = nil + installPlugin(for: type) + } + .controlSize(.small) + } + } else { + HStack(spacing: 6) { + Text("Not installed") + .foregroundStyle(.secondary) + Button("Install") { + installPlugin(for: type) + } + .controlSize(.small) + } } - .buttonStyle(.borderedProminent) } } } else if PluginManager.shared.connectionMode(for: type) == .fileBased { From 30be8f61b6cc68794545277bcbfedd9007dfe5c6 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 22 Mar 2026 17:46:34 +0700 Subject: [PATCH 7/8] fix: reset install state on type change and consistent casing --- TablePro/Views/Connection/ConnectionFormView.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index 9ad9f126..e14e8002 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -187,6 +187,8 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length if !visibleTabs.contains(selectedTab) { selectedTab = .general } + isInstallingPlugin = false + pluginInstallError = nil } .pluginInstallPrompt(connection: $pluginInstallConnection) { connection in connectAfterInstall(connection) @@ -292,7 +294,7 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length } } else { HStack(spacing: 6) { - Text("Not installed") + Text("Not Installed") .foregroundStyle(.secondary) Button("Install") { installPlugin(for: type) From d3e2622c89114d5f6cb32fe87802c375980bb9a8 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 22 Mar 2026 17:49:19 +0700 Subject: [PATCH 8/8] fix: disable form controls during plugin install and seed field defaults after install --- TablePro/Views/Connection/ConnectionFormView.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index e14e8002..5263faa2 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -258,6 +258,7 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length .tag(t) } } + .disabled(isInstallingPlugin) TextField( String(localized: "Name"), text: $name, @@ -961,7 +962,7 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length Text("Test Connection") } } - .disabled(isTesting || !isValid) + .disabled(isTesting || isInstallingPlugin || !isValid) Spacer() @@ -993,7 +994,7 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length } .keyboardShortcut(.return) .buttonStyle(.borderedProminent) - .disabled(!isValid) + .disabled(isInstallingPlugin || !isValid) } .padding(.horizontal, 16) .padding(.vertical, 12) @@ -1500,6 +1501,13 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length Task { do { try await PluginManager.shared.installMissingPlugin(for: databaseType) { _ in } + if type == databaseType { + for field in PluginManager.shared.additionalConnectionFields(for: databaseType) { + if additionalFieldValues[field.id] == nil, let defaultValue = field.defaultValue { + additionalFieldValues[field.id] = defaultValue + } + } + } } catch { pluginInstallError = error.localizedDescription }