diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index 11655f0d..7b21e4be 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -612,8 +612,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Enable native macOS window tabbing (Finder/Safari-style tabs) NSWindow.allowsAutomaticWindowTabbing = true - // Load plugins (driver plugins, etc.) before any connections are created - PluginManager.shared.loadAllPlugins() + // Discover and load plugins (discovery is synchronous, bundle loading is deferred) + PluginManager.shared.loadPlugins() // Start license periodic validation Task { @MainActor in diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index 43273b71..953166f5 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -45,7 +45,16 @@ final class PluginManager { // MARK: - Loading - func loadAllPlugins() { + /// Discover and load all plugins. Discovery is synchronous (reads Info.plist), + /// then bundle loading is deferred to the next run loop iteration so it doesn't block app launch. + func loadPlugins() { + discoverAllPlugins() + Task { @MainActor in + self.loadPendingPlugins() + } + } + + private func discoverAllPlugins() { let fm = FileManager.default if !fm.fileExists(atPath: userPluginsDir.path) { do { @@ -65,7 +74,7 @@ final class PluginManager { } /// Load all discovered but not-yet-loaded plugin bundles. - /// Called on first driver request or when the plugins settings screen opens. + /// Safety fallback for code paths that need plugins before the deferred Task completes. func loadPendingPlugins() { guard !pendingPluginURLs.isEmpty else { return } let pending = pendingPluginURLs @@ -285,6 +294,7 @@ final class PluginManager { // MARK: - Driver Availability func isDriverAvailable(for databaseType: DatabaseType) -> Bool { + // Safety fallback: loads pending plugins if the deferred startup Task hasn't completed yet loadPendingPlugins() return driverPlugins[databaseType.pluginTypeId] != nil } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 63762007..2e807fe4 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -3749,6 +3749,16 @@ } } }, + "Connection Test Failed" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kiểm tra kết nối thất bại" + } + } + } + }, "Connection URL" : { "localizations" : { "vi" : { diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index 97785b2a..131eb4c8 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -85,11 +85,11 @@ struct ConnectionFormView: View { @State private var startupCommands: String = "" @State private var isTesting: Bool = false - @State private var testResult: TestResult? + @State private var testSucceeded: Bool = false @State private var isInstallingPlugin = false @State private var pluginInstallProgress: Double = 0 - @State private var showPluginInstallError: String? + @State private var pluginInstallConnection: DatabaseConnection? // Tab selection @@ -100,11 +100,6 @@ struct ConnectionFormView: View { // MARK: - Enums - enum TestResult { - case success - case failure(String) - } - private enum FormTab: String, CaseIterable { case general = "General" case ssh = "SSH Tunnel" @@ -139,7 +134,6 @@ struct ConnectionFormView: View { isNew ? String(localized: "New Connection") : String(localized: "Edit Connection") ) .onAppear { - PluginManager.shared.loadPendingPlugins() loadConnectionData() loadSSHConfig() } @@ -680,32 +674,6 @@ struct ConnectionFormView: View { .padding(.top, 8) } - if case .failure(let message) = testResult { - HStack(alignment: .top, spacing: 6) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.red) - Text(message) - .font(.caption) - .foregroundStyle(.red) - .fixedSize(horizontal: false, vertical: true) - } - .padding(.horizontal, 16) - .padding(.top, 8) - } - - if let pluginError = showPluginInstallError { - HStack(alignment: .top, spacing: 6) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.red) - Text(pluginError) - .font(.caption) - .foregroundStyle(.red) - .fixedSize(horizontal: false, vertical: true) - } - .padding(.horizontal, 16) - .padding(.top, 8) - } - HStack { // Test connection Button(action: testConnection) { @@ -714,8 +682,8 @@ struct ConnectionFormView: View { ProgressView() .controlSize(.small) } else { - Image(systemName: testResultIcon) - .foregroundStyle(testResultColor) + Image(systemName: testSucceeded ? "checkmark.circle.fill" : "bolt.horizontal") + .foregroundStyle(testSucceeded ? .green : .secondary) } Text("Test Connection") } @@ -783,26 +751,10 @@ struct ConnectionFormView: View { return basicValid } - private var testResultIcon: String { - switch testResult { - case .success: return "checkmark.circle.fill" - case .failure: return "xmark.circle.fill" - case .none: return "bolt.horizontal" - } - } - - private var testResultColor: Color { - switch testResult { - case .success: return .green - case .failure: return .red - case .none: return .secondary - } - } - private func installPluginForType(_ databaseType: DatabaseType) { isInstallingPlugin = true pluginInstallProgress = 0 - showPluginInstallError = nil + let window = NSApp.keyWindow Task { do { @@ -812,7 +764,11 @@ struct ConnectionFormView: View { isInstallingPlugin = false } catch { isInstallingPlugin = false - showPluginInstallError = error.localizedDescription + AlertHelper.showErrorSheet( + title: String(localized: "Plugin Installation Failed"), + message: error.localizedDescription, + window: window + ) } } } @@ -1012,7 +968,8 @@ struct ConnectionFormView: View { func testConnection() { isTesting = true - testResult = nil + testSucceeded = false + let window = NSApp.keyWindow // Build SSH config let sshConfig = SSHConfiguration( @@ -1080,13 +1037,24 @@ struct ConnectionFormView: View { testConn, sshPassword: sshPassword) await MainActor.run { isTesting = false - testResult = - success ? .success : .failure(String(localized: "Connection test failed")) + if success { + testSucceeded = true + } else { + AlertHelper.showErrorSheet( + title: String(localized: "Connection Test Failed"), + message: String(localized: "Connection test failed"), + window: window + ) + } } } catch { await MainActor.run { isTesting = false - testResult = .failure(error.localizedDescription) + AlertHelper.showErrorSheet( + title: String(localized: "Connection Test Failed"), + message: error.localizedDescription, + window: window + ) } } } diff --git a/TablePro/Views/Export/ExportDialog.swift b/TablePro/Views/Export/ExportDialog.swift index afec5710..49801e01 100644 --- a/TablePro/Views/Export/ExportDialog.swift +++ b/TablePro/Views/Export/ExportDialog.swift @@ -63,7 +63,6 @@ struct ExportDialog: View { .frame(width: dialogWidth) .background(Color(nsColor: .windowBackgroundColor)) .onAppear { - PluginManager.shared.loadPendingPlugins() let available = availableFormats if !available.contains(where: { type(of: $0).formatId == config.formatId }) { if let first = available.first { diff --git a/TablePro/Views/Import/ImportDialog.swift b/TablePro/Views/Import/ImportDialog.swift index c0b11d10..0e870ff6 100644 --- a/TablePro/Views/Import/ImportDialog.swift +++ b/TablePro/Views/Import/ImportDialog.swift @@ -67,7 +67,6 @@ struct ImportDialog: View { } .background(Color(nsColor: .windowBackgroundColor)) .onAppear { - PluginManager.shared.loadPendingPlugins() let available = availableFormats if !available.contains(where: { type(of: $0).formatId == selectedFormatId }) { if let first = available.first { diff --git a/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift b/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift index 93110d8a..507dc199 100644 --- a/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift +++ b/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift @@ -81,9 +81,6 @@ struct InstalledPluginsView: View { } return true } - .onAppear { - pluginManager.loadPendingPlugins() - } .alert(errorAlertTitle, isPresented: $showErrorAlert) { Button("OK") {} } message: { diff --git a/TableProTests/Core/Services/ImportStateTests.swift b/TableProTests/Core/Services/ImportStateTests.swift index f4418eab..73365ea0 100644 --- a/TableProTests/Core/Services/ImportStateTests.swift +++ b/TableProTests/Core/Services/ImportStateTests.swift @@ -16,9 +16,8 @@ struct ImportStateTests { let state = ImportState() #expect(state.isImporting == false) #expect(state.progress == 0.0) - #expect(state.currentStatement == "") - #expect(state.currentStatementIndex == 0) - #expect(state.totalStatements == 0) + #expect(state.processedStatements == 0) + #expect(state.estimatedTotalStatements == 0) #expect(state.statusMessage == "") #expect(state.errorMessage == nil) } @@ -46,7 +45,7 @@ struct ImportStateTests { #expect(state.isImporting == true) #expect(state.progress == 0.0) - #expect(state.currentStatement == "") + #expect(state.processedStatements == 0) #expect(state.errorMessage == nil) } @@ -60,14 +59,11 @@ struct ImportStateTests { state.progress = 0.75 #expect(state.progress == 0.75) - state.currentStatement = "CREATE TABLE test" - #expect(state.currentStatement == "CREATE TABLE test") + state.processedStatements = 5 + #expect(state.processedStatements == 5) - state.currentStatementIndex = 5 - #expect(state.currentStatementIndex == 5) - - state.totalStatements = 20 - #expect(state.totalStatements == 20) + state.estimatedTotalStatements = 20 + #expect(state.estimatedTotalStatements == 20) state.statusMessage = "Importing..." #expect(state.statusMessage == "Importing...") diff --git a/TableProTests/Views/Import/ImportServiceStateTests.swift b/TableProTests/Views/Import/ImportServiceStateTests.swift index 7cf3fc23..bdadc909 100644 --- a/TableProTests/Views/Import/ImportServiceStateTests.swift +++ b/TableProTests/Views/Import/ImportServiceStateTests.swift @@ -20,9 +20,8 @@ struct ImportServiceStateTests { #expect(state.service == nil) #expect(state.isImporting == false) - #expect(state.currentStatement == "") - #expect(state.currentStatementIndex == 0) - #expect(state.totalStatements == 0) + #expect(state.processedStatements == 0) + #expect(state.estimatedTotalStatements == 0) #expect(state.statusMessage == "") } @@ -36,18 +35,16 @@ struct ImportServiceStateTests { service.state = ImportState( isImporting: true, - currentStatement: "CREATE TABLE users", - currentStatementIndex: 3, - totalStatements: 10, + processedStatements: 3, + estimatedTotalStatements: 10, statusMessage: "Importing..." ) state.setService(service) #expect(state.isImporting == true) - #expect(state.currentStatement == "CREATE TABLE users") - #expect(state.currentStatementIndex == 3) - #expect(state.totalStatements == 10) + #expect(state.processedStatements == 3) + #expect(state.estimatedTotalStatements == 10) #expect(state.statusMessage == "Importing...") } @@ -62,18 +59,15 @@ struct ImportServiceStateTests { state.setService(service) #expect(state.isImporting == false) - #expect(state.currentStatement == "") service.state.isImporting = true - service.state.currentStatement = "INSERT INTO orders" - service.state.currentStatementIndex = 7 - service.state.totalStatements = 20 + service.state.processedStatements = 7 + service.state.estimatedTotalStatements = 20 service.state.statusMessage = "Processing statements..." #expect(state.isImporting == true) - #expect(state.currentStatement == "INSERT INTO orders") - #expect(state.currentStatementIndex == 7) - #expect(state.totalStatements == 20) + #expect(state.processedStatements == 7) + #expect(state.estimatedTotalStatements == 20) #expect(state.statusMessage == "Processing statements...") } }