diff --git a/CHANGELOG.md b/CHANGELOG.md index dec4026f1..793aa36dc 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 d058d691d..20a97c9fd 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 56e49508a..5263faa20 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] { @@ -126,6 +126,8 @@ 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 + @State private var pluginInstallError: String? // Tab selection @State private var selectedTab: FormTab = .general @@ -185,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) @@ -237,9 +241,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: { @@ -251,6 +258,7 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length .tag(t) } } + .disabled(isInstallingPlugin) TextField( String(localized: "Name"), text: $name, @@ -263,7 +271,41 @@ 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 { + 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) + } + } + } + } + } else if PluginManager.shared.connectionMode(for: type) == .fileBased { Section(String(localized: "Database File")) { HStack { TextField( @@ -920,7 +962,7 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length Text("Test Connection") } } - .disabled(isTesting || !isValid) + .disabled(isTesting || isInstallingPlugin || !isValid) Spacer() @@ -952,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) @@ -1454,6 +1496,25 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length } } + private func installPlugin(for databaseType: DatabaseType) { + isInstallingPlugin = true + 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 + } + isInstallingPlugin = false + } + } + private func cleanupTestSecrets(for testId: UUID) { ConnectionStorage.shared.deletePassword(for: testId) ConnectionStorage.shared.deleteSSHPassword(for: testId)