diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index c4abf986..5339837c 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -56,7 +56,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { let passwordSyncExpected = syncSettings.enabled && syncSettings.syncConnections && syncSettings.syncPasswords let previousSyncState = UserDefaults.standard.bool(forKey: KeychainHelper.passwordSyncEnabledKey) UserDefaults.standard.set(passwordSyncExpected, forKey: KeychainHelper.passwordSyncEnabledKey) - KeychainHelper.shared.migrateFromLegacyKeychainIfNeeded() + Task.detached(priority: .utility) { + KeychainHelper.shared.migrateFromLegacyKeychainIfNeeded() + } if passwordSyncExpected != previousSyncState { Task.detached(priority: .background) { KeychainHelper.shared.migratePasswordSyncState(synchronizable: passwordSyncExpected) diff --git a/TablePro/ContentView.swift b/TablePro/ContentView.swift index 5235e494..b25a9ae0 100644 --- a/TablePro/ContentView.swift +++ b/TablePro/ContentView.swift @@ -40,7 +40,7 @@ struct ContentView: View { if let tableName = payload?.tableName { defaultTitle = tableName } else if let connectionId = payload?.connectionId, - let connection = ConnectionStorage.shared.loadConnections().first(where: { $0.id == connectionId }) { + let connection = DatabaseManager.shared.activeSessions[connectionId]?.connection { let langName = PluginManager.shared.queryLanguageName(for: connection.type) defaultTitle = "\(langName) Query" } else { @@ -102,7 +102,7 @@ struct ContentView: View { columnVisibility = .detailOnly } } - .onChange(of: DatabaseManager.shared.connectionStatusVersion, initial: true) { _, _ in + .onChange(of: (payload?.connectionId ?? currentSession?.id).flatMap { DatabaseManager.shared.connectionStatusVersions[$0] }, initial: true) { _, _ in let sessions = DatabaseManager.shared.activeSessions let connectionId = payload?.connectionId ?? currentSession?.id ?? DatabaseManager.shared.currentSessionId guard let sid = connectionId else { diff --git a/TablePro/Core/ChangeTracking/DataChangeManager.swift b/TablePro/Core/ChangeTracking/DataChangeManager.swift index eb695df3..a145ac64 100644 --- a/TablePro/Core/ChangeTracking/DataChangeManager.swift +++ b/TablePro/Core/ChangeTracking/DataChangeManager.swift @@ -88,6 +88,21 @@ final class DataChangeManager { return true } + /// Binary search: count of elements in a sorted array that are strictly less than `target`. + /// Used for O(n log n) batch index shifting instead of O(n²) nested loops. + private static func countLessThan(_ target: Int, in sorted: [Int]) -> Int { + var lo = 0, hi = sorted.count + while lo < hi { + let mid = (lo + hi) / 2 + if sorted[mid] < target { + lo = mid + 1 + } else { + hi = mid + } + } + return lo + } + /// Undo/redo manager private let undoManager = DataChangeUndoManager() @@ -414,18 +429,20 @@ final class DataChangeManager { pushUndo(.batchRowInsertion(rowIndices: validRows, rowValues: rowValues)) - for deletedIndex in validRows.reversed() { - var shiftedIndices = Set() - for idx in insertedRowIndices { - shiftedIndices.insert(idx > deletedIndex ? idx - 1 : idx) - } - insertedRowIndices = shiftedIndices + // Single-pass shift using binary search instead of O(n²) nested loop + let sortedDeleted = validRows.sorted() - for i in 0.. deletedIndex { - changes[i].rowIndex -= 1 - } - } + var newInserted = Set() + for idx in insertedRowIndices { + let shiftCount = Self.countLessThan(idx, in: sortedDeleted) + newInserted.insert(idx - shiftCount) + } + insertedRowIndices = newInserted + + for i in 0.. \(String(describing: newState))" ) + await onStateChanged(connectionId, newState) } - - await onStateChanged(connectionId, newState) } } diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index d3d185cf..a86c0ec7 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -311,7 +311,10 @@ extension DatabaseDriver { enum DatabaseDriverFactory { static func createDriver(for connection: DatabaseConnection) throws -> DatabaseDriver { let pluginId = connection.type.pluginTypeId - if PluginManager.shared.driverPlugins[pluginId] == nil { + // If the plugin isn't registered yet and background loading hasn't finished, + // fall back to synchronous loading for this critical code path. + if PluginManager.shared.driverPlugins[pluginId] == nil, + !PluginManager.shared.hasFinishedInitialLoad { PluginManager.shared.loadPendingPlugins() } guard let plugin = PluginManager.shared.driverPlugins[pluginId] else { diff --git a/TablePro/Core/Database/DatabaseManager.swift b/TablePro/Core/Database/DatabaseManager.swift index ff947de4..e851c853 100644 --- a/TablePro/Core/Database/DatabaseManager.swift +++ b/TablePro/Core/Database/DatabaseManager.swift @@ -32,6 +32,10 @@ final class DatabaseManager { /// Incremented when any session state changes (status, driver, metadata, etc.). private(set) var connectionStatusVersion: Int = 0 + /// Per-connection version counters. Views observe their specific connection's + /// counter to avoid cross-connection re-renders. + private(set) var connectionStatusVersions: [UUID: Int] = [:] + /// Backward-compatible alias for views not yet migrated to fine-grained counters. var sessionVersion: Int { connectionStatusVersion } @@ -39,12 +43,12 @@ final class DatabaseManager { private(set) var currentSessionId: UUID? /// Health monitors for active connections (MySQL/PostgreSQL only) - private var healthMonitors: [UUID: ConnectionHealthMonitor] = [:] + @ObservationIgnored private var healthMonitors: [UUID: ConnectionHealthMonitor] = [:] /// Tracks connections with user queries currently in-flight. /// The health monitor skips pings while a query is running to avoid /// racing on non-thread-safe driver connections. - private var queriesInFlight: [UUID: Int] = [:] + @ObservationIgnored private var queriesInFlight: [UUID: Int] = [:] /// Current session (computed from currentSessionId) var currentSession: ConnectionSession? { @@ -90,7 +94,7 @@ final class DatabaseManager { if activeSessions[connection.id] == nil { var session = ConnectionSession(connection: connection) session.status = .connecting - activeSessions[connection.id] = session + setSession(session, for: connection.id) } currentSessionId = connection.id @@ -100,7 +104,7 @@ final class DatabaseManager { effectiveConnection = try await buildEffectiveConnection(for: connection) } catch { // Remove failed session - activeSessions.removeValue(forKey: connection.id) + removeSessionEntry(for: connection.id) currentSessionId = nil throw error } @@ -112,7 +116,7 @@ final class DatabaseManager { do { try await PreConnectHookRunner.run(script: script) } catch { - activeSessions.removeValue(forKey: connection.id) + removeSessionEntry(for: connection.id) currentSessionId = nil throw error } @@ -129,7 +133,7 @@ final class DatabaseManager { try? await SSHTunnelManager.shared.closeTunnel(connectionId: connection.id) } } - activeSessions.removeValue(forKey: connection.id) + removeSessionEntry(for: connection.id) currentSessionId = nil throw error } @@ -195,7 +199,7 @@ final class DatabaseManager { session.status = driver.status session.effectiveConnection = effectiveConnection - activeSessions[connection.id] = session // Single write, single publish + setSession(session, for: connection.id) } // Save as last connection for "Reopen Last Session" feature @@ -221,7 +225,7 @@ final class DatabaseManager { } // Remove failed session completely so UI returns to Welcome window - activeSessions.removeValue(forKey: connection.id) + removeSessionEntry(for: connection.id) // Clear current session if this was it if currentSessionId == connection.id { @@ -239,12 +243,11 @@ final class DatabaseManager { /// Switch to an existing session func switchToSession(_ sessionId: UUID) { - guard var session = activeSessions[sessionId] else { return } + guard activeSessions[sessionId] != nil else { return } currentSessionId = sessionId - - // Mark session as active - session.markActive() - activeSessions[sessionId] = session + updateSession(sessionId) { session in + session.markActive() + } } /// Disconnect a specific session @@ -260,7 +263,7 @@ final class DatabaseManager { await stopHealthMonitor(for: sessionId) session.driver?.disconnect() - activeSessions.removeValue(forKey: sessionId) + removeSessionEntry(for: sessionId) // Clean up shared schema cache for this connection SchemaProviderRegistry.shared.clear(for: sessionId) @@ -293,33 +296,50 @@ final class DatabaseManager { } } - /// Update session state (for preserving UI state) + /// Update session state (for preserving UI state). + /// Skips the write-back when no observable fields changed, avoiding spurious connectionStatusVersion bumps. func updateSession(_ sessionId: UUID, update: (inout ConnectionSession) -> Void) { guard var session = activeSessions[sessionId] else { return } + let before = session + let driverBefore = session.driver as AnyObject? update(&session) - activeSessions[sessionId] = session + let driverAfter = session.driver as AnyObject? + guard !session.isContentViewEquivalent(to: before) || driverBefore !== driverAfter else { return } + setSession(session, for: sessionId) + } + + /// Write a session and bump its per-connection version counter. + private func setSession(_ session: ConnectionSession, for connectionId: UUID) { + activeSessions[connectionId] = session + connectionStatusVersions[connectionId, default: 0] &+= 1 + } + + /// Remove a session and clean up its per-connection version counter. + private func removeSessionEntry(for connectionId: UUID) { + activeSessions.removeValue(forKey: connectionId) + connectionStatusVersions.removeValue(forKey: connectionId) } #if DEBUG /// Test-only: inject a session for unit testing without real database connections internal func injectSession(_ session: ConnectionSession, for connectionId: UUID) { - activeSessions[connectionId] = session + setSession(session, for: connectionId) } /// Test-only: remove an injected session internal func removeSession(for connectionId: UUID) { - activeSessions.removeValue(forKey: connectionId) + removeSessionEntry(for: connectionId) } #endif // MARK: - Query Execution (uses current session) - /// Execute a query on the current session - func execute(query: String) async throws -> QueryResult { - guard let sessionId = currentSessionId, let driver = activeDriver else { - throw DatabaseError.notConnected - } - + /// Track an in-flight operation for the given session, preventing health monitor + /// pings from racing on the same non-thread-safe driver connection. + private func trackOperation( + sessionId: UUID, + operation: () async throws -> T + ) async throws -> T { queriesInFlight[sessionId, default: 0] += 1 defer { if let count = queriesInFlight[sessionId], count > 1 { @@ -328,25 +348,40 @@ final class DatabaseManager { queriesInFlight.removeValue(forKey: sessionId) } } - return try await driver.execute(query: query) + return try await operation() + } + + /// Execute a query on the current session + func execute(query: String) async throws -> QueryResult { + guard let sessionId = currentSessionId, let driver = activeDriver else { + throw DatabaseError.notConnected + } + + return try await trackOperation(sessionId: sessionId) { + try await driver.execute(query: query) + } } /// Fetch tables from the current session func fetchTables() async throws -> [TableInfo] { - guard let driver = activeDriver else { + guard let sessionId = currentSessionId, let driver = activeDriver else { throw DatabaseError.notConnected } - return try await driver.fetchTables() + return try await trackOperation(sessionId: sessionId) { + try await driver.fetchTables() + } } /// Fetch columns for a table from the current session func fetchColumns(table: String) async throws -> [ColumnInfo] { - guard let driver = activeDriver else { + guard let sessionId = currentSessionId, let driver = activeDriver else { throw DatabaseError.notConnected } - return try await driver.fetchColumns(table: table) + return try await trackOperation(sessionId: sessionId) { + try await driver.fetchColumns(table: table) + } } /// Test a connection without keeping it open @@ -511,9 +546,13 @@ final class DatabaseManager { } } case .reconnecting(let attempt): - Self.logger.info("Reconnecting session \(id) (attempt \(attempt)/3)") - self.updateSession(id) { session in - session.status = .connecting + Self.logger.info("Reconnecting session \(id) (attempt \(attempt))") + if case .connecting = self.activeSessions[id]?.status { + // Already .connecting — skip redundant write + } else { + self.updateSession(id) { session in + session.status = .connecting + } } case .failed: Self.logger.error( @@ -761,41 +800,43 @@ final class DatabaseManager { throw DatabaseError.notConnected } - // For PostgreSQL PK modification, query the actual constraint name - let pkConstraintName = await fetchPrimaryKeyConstraintName( - tableName: tableName, - databaseType: databaseType, - changes: changes, - driver: driver - ) + try await trackOperation(sessionId: connectionId) { + // For PostgreSQL PK modification, query the actual constraint name + let pkConstraintName = await fetchPrimaryKeyConstraintName( + tableName: tableName, + databaseType: databaseType, + changes: changes, + driver: driver + ) - guard let resolvedPluginDriver = (driver as? PluginDriverAdapter)?.schemaPluginDriver else { - throw DatabaseError.unsupportedOperation - } + guard let resolvedPluginDriver = (driver as? PluginDriverAdapter)?.schemaPluginDriver else { + throw DatabaseError.unsupportedOperation + } - let generator = SchemaStatementGenerator( - tableName: tableName, - primaryKeyConstraintName: pkConstraintName, - pluginDriver: resolvedPluginDriver - ) - let statements = try generator.generate(changes: changes) + let generator = SchemaStatementGenerator( + tableName: tableName, + primaryKeyConstraintName: pkConstraintName, + pluginDriver: resolvedPluginDriver + ) + let statements = try generator.generate(changes: changes) - // Execute in transaction - try await driver.beginTransaction() + // Execute in transaction + try await driver.beginTransaction() - do { - for stmt in statements { - _ = try await driver.execute(query: stmt.sql) - } + do { + for stmt in statements { + _ = try await driver.execute(query: stmt.sql) + } - try await driver.commitTransaction() + try await driver.commitTransaction() - // Post notification to refresh UI - NotificationCenter.default.post(name: .refreshData, object: nil) - } catch { - // Rollback on error - try? await driver.rollbackTransaction() - throw DatabaseError.queryFailed("Schema change failed: \(error.localizedDescription)") + // Post notification to refresh UI + NotificationCenter.default.post(name: .refreshData, object: nil) + } catch { + // Rollback on error + try? await driver.rollbackTransaction() + throw DatabaseError.queryFailed("Schema change failed: \(error.localizedDescription)") + } } } diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index 0352267e..dff69bca 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -20,6 +20,9 @@ final class PluginManager { private(set) var isInstalling = false + /// True once the initial plugin discovery + loading pass has completed. + private(set) var hasFinishedInitialLoad = false + private static let needsRestartKey = "com.TablePro.needsRestart" private var _needsRestart: Bool = UserDefaults.standard.bool( @@ -69,12 +72,140 @@ final class PluginManager { // MARK: - Loading /// 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. + /// then bundle loading runs on a background thread to avoid blocking the UI. + /// Only the final registration into dictionaries happens on MainActor. func loadPlugins() { migrateDisabledPluginsKey() discoverAllPlugins() - Task { @MainActor in - self.loadPendingPlugins(clearRestartFlag: true) + let pending = pendingPluginURLs + Task { + let loaded = await Self.loadBundlesOffMain(pending) + self.pendingPluginURLs.removeAll() + self._needsRestart = false + self.registerLoadedPlugins(loaded) + self.validateDependencies() + self.hasFinishedInitialLoad = true + Self.logger.info("Loaded \(self.plugins.count) plugin(s): \(self.driverPlugins.count) driver(s), \(self.exportPlugins.count) export format(s), \(self.importPlugins.count) import format(s)") + } + } + + /// 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(). + 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. + nonisolated private static func loadBundlesOffMain( + _ pending: [(url: URL, source: PluginSource)] + ) async -> [LoadedBundle] { + var results: [LoadedBundle] = [] + for entry in pending { + guard let bundle = Bundle(url: entry.url) else { + logger.error("Cannot create bundle from \(entry.url.lastPathComponent)") + continue + } + + let infoPlist = bundle.infoDictionary ?? [:] + let pluginKitVersion = infoPlist["TableProPluginKitVersion"] as? Int ?? 0 + if pluginKitVersion > currentPluginKitVersion { + logger.error("Plugin \(entry.url.lastPathComponent) requires PluginKit v\(pluginKitVersion), current is v\(currentPluginKitVersion)") + continue + } + + if let minAppVersion = infoPlist["TableProMinAppVersion"] as? String { + let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0" + if appVersion.compare(minAppVersion, options: .numeric) == .orderedAscending { + logger.error("Plugin \(entry.url.lastPathComponent) requires app v\(minAppVersion)") + continue + } + } + + if entry.source == .userInstalled { + if pluginKitVersion < currentPluginKitVersion { + logger.error("User plugin \(entry.url.lastPathComponent) has outdated PluginKit v\(pluginKitVersion)") + continue + } + } + + // Heavy I/O: dynamic linker resolution, C bridge library initialization + guard bundle.load() else { + logger.error("Bundle failed to load executable: \(entry.url.lastPathComponent)") + 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 loaded = LoadedBundle( + url: entry.url, + source: entry.source, + bundle: bundle, + principalClassName: NSStringFromClass(principalClass), + pluginName: principalClass.pluginName, + pluginVersion: principalClass.pluginVersion, + pluginDescription: principalClass.pluginDescription, + capabilities: principalClass.capabilities, + databaseTypeId: driverType?.databaseTypeId, + additionalTypeIds: driverType?.additionalDatabaseTypeIds ?? [], + pluginIconName: driverType?.iconName ?? "puzzlepiece", + defaultPort: driverType?.defaultPort + ) + results.append(loaded) + } + return results + } + + /// Register pre-loaded bundles into the plugin dictionaries. Must be called on MainActor. + private func registerLoadedPlugins(_ loaded: [LoadedBundle]) { + let disabled = disabledPluginIds + + for item in loaded { + let bundleId = item.bundle.bundleIdentifier ?? item.url.lastPathComponent + 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, + isEnabled: !disabled.contains(bundleId), + databaseTypeId: item.databaseTypeId, + additionalTypeIds: item.additionalTypeIds, + pluginIconName: item.pluginIconName, + defaultPort: item.defaultPort + ) + + plugins.append(entry) + + 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) + } + } + + Self.logger.info("Loaded plugin '\(entry.name)' v\(entry.version) [\(item.source == .builtIn ? "built-in" : "user")]") } } @@ -97,8 +228,9 @@ final class PluginManager { Self.logger.info("Discovered \(self.pendingPluginURLs.count) plugin(s), will load on first use") } - /// Load all discovered but not-yet-loaded plugin bundles. - /// Safety fallback for code paths that need plugins before the deferred Task completes. + /// Load all discovered but not-yet-loaded plugin bundles synchronously on MainActor. + /// Only used by install/uninstall paths that need immediate plugin availability. + /// Normal startup uses `loadPlugins()` which loads bundles off the main thread. func loadPendingPlugins(clearRestartFlag: Bool = false) { if clearRestartFlag { _needsRestart = false @@ -115,6 +247,7 @@ final class PluginManager { } } + hasFinishedInitialLoad = true validateDependencies() Self.logger.info("Loaded \(self.plugins.count) plugin(s): \(self.driverPlugins.count) driver(s), \(self.exportPlugins.count) export format(s), \(self.importPlugins.count) import format(s)") } @@ -458,9 +591,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 + driverPlugins[databaseType.pluginTypeId] != nil } func isDriverLoaded(for databaseType: DatabaseType) -> Bool { @@ -485,8 +616,7 @@ final class PluginManager { // MARK: - Plugin Property Lookups func driverPlugin(for databaseType: DatabaseType) -> (any DriverPlugin)? { - loadPendingPlugins() - return driverPlugins[databaseType.pluginTypeId] + driverPlugins[databaseType.pluginTypeId] } /// Returns a temporary plugin driver for query building (buildBrowseQuery), or nil diff --git a/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift b/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift index 0130c3b1..23ead600 100644 --- a/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift +++ b/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift @@ -16,7 +16,7 @@ internal final class WindowLifecycleMonitor { private struct Entry { let connectionId: UUID - let window: NSWindow + weak var window: NSWindow? var observer: NSObjectProtocol? var isPreview: Bool = false } @@ -77,55 +77,69 @@ internal final class WindowLifecycleMonitor { /// Return all live windows for a connection. internal func windows(for connectionId: UUID) -> [NSWindow] { - entries.values + purgeStaleEntries() + return entries.values .filter { $0.connectionId == connectionId } - .map(\.window) + .compactMap(\.window) } /// Check if other live windows exist for a connection, excluding a specific windowId. internal func hasOtherWindows(for connectionId: UUID, excluding windowId: UUID) -> Bool { - entries.contains { key, value in + purgeStaleEntries() + return entries.contains { key, value in key != windowId && value.connectionId == connectionId } } /// All connection IDs that currently have registered windows. internal func allConnectionIds() -> Set { - Set(entries.values.map(\.connectionId)) + purgeStaleEntries() + return Set(entries.values.map(\.connectionId)) } /// Find the first visible window for a connection. internal func findWindow(for connectionId: UUID) -> NSWindow? { - entries.values + purgeStaleEntries() + return entries.values .filter { $0.connectionId == connectionId } - .map(\.window) + .compactMap(\.window) .first { $0.isVisible } } /// Look up the connectionId for a given windowId. internal func connectionId(for windowId: UUID) -> UUID? { - entries[windowId]?.connectionId + purgeStaleEntries() + return entries[windowId]?.connectionId } /// Check if any windows are registered for a connection. internal func hasWindows(for connectionId: UUID) -> Bool { - entries.values.contains { $0.connectionId == connectionId } + purgeStaleEntries() + return entries.values.contains { $0.connectionId == connectionId } } - /// Check if a specific window is still registered + /// Check if a specific window is still registered (with a live NSWindow reference). internal func isRegistered(windowId: UUID) -> Bool { - entries[windowId] != nil + guard entries[windowId] != nil else { return false } + purgeStaleEntries() + return entries[windowId] != nil } /// Find the first preview window for a connection. internal func previewWindow(for connectionId: UUID) -> (windowId: UUID, window: NSWindow)? { - entries.first { $0.value.connectionId == connectionId && $0.value.isPreview } - .map { ($0.key, $0.value.window) } + purgeStaleEntries() + for (windowId, entry) in entries { + guard entry.connectionId == connectionId, entry.isPreview else { continue } + guard let window = entry.window else { continue } + return (windowId, window) + } + return nil } /// Look up the NSWindow for a given windowId. internal func window(for windowId: UUID) -> NSWindow? { - entries[windowId]?.window + purgeStaleEntries() + return entries[windowId]?.window } /// Update the preview flag for a registered window. @@ -135,6 +149,19 @@ internal final class WindowLifecycleMonitor { // MARK: - Private + /// Remove entries whose window has already been deallocated. + private func purgeStaleEntries() { + let staleIds = entries.compactMap { key, value -> UUID? in + value.window == nil ? key : nil + } + for windowId in staleIds { + let entry = entries.removeValue(forKey: windowId) + if let observer = entry?.observer { + NotificationCenter.default.removeObserver(observer) + } + } + } + private func handleWindowClose(_ closedWindow: NSWindow) { guard let (windowId, entry) = entries.first(where: { $0.value.window === closedWindow }) else { return @@ -147,7 +174,9 @@ internal final class WindowLifecycleMonitor { } entries.removeValue(forKey: windowId) - let hasRemainingWindows = entries.values.contains { $0.connectionId == closedConnectionId } + let hasRemainingWindows = entries.values.contains { + $0.connectionId == closedConnectionId && $0.window != nil + } if !hasRemainingWindows { Task { await DatabaseManager.shared.disconnectSession(closedConnectionId) diff --git a/TablePro/Models/Query/RowProvider.swift b/TablePro/Models/Query/RowProvider.swift index a058c942..2bfc9bdb 100644 --- a/TablePro/Models/Query/RowProvider.swift +++ b/TablePro/Models/Query/RowProvider.swift @@ -269,6 +269,8 @@ final class DatabaseRowProvider: RowProvider { private let baseQuery: String private var cache: [Int: TableRowData] = [:] private let pageSize: Int + private var prefetchTask: Task? + private var inFlightRange: Range? private(set) var totalRowCount: Int = 0 private(set) var columns: [String] @@ -317,22 +319,44 @@ final class DatabaseRowProvider: RowProvider { let offset = minIndex let limit = min(maxIndex - minIndex + pageSize, totalRowCount - offset) + let fetchRange = offset..<(offset + limit) - Task { @MainActor in + if let inFlight = inFlightRange, + inFlight.contains(offset) && inFlight.contains(offset + limit - 1) { + return + } + + prefetchTask?.cancel() + let driver = self.driver + let baseQuery = self.baseQuery + + inFlightRange = fetchRange + prefetchTask = Task { [weak self] in do { let result = try await driver.fetchRows(query: baseQuery, offset: offset, limit: limit) - for (i, row) in result.rows.enumerated() { - let rowData = TableRowData(index: offset + i, values: row) - cache[offset + i] = rowData + guard !Task.isCancelled else { return } + await MainActor.run { [weak self] in + guard let self else { return } + for (i, row) in result.rows.enumerated() { + self.cache[offset + i] = TableRowData(index: offset + i, values: row) + } + self.evictCacheIfNeeded(nearIndex: offset) + self.inFlightRange = nil } - evictCacheIfNeeded(nearIndex: offset) } catch { + guard !Task.isCancelled else { return } Self.logger.error("Prefetch error: \(error)") + await MainActor.run { [weak self] in + self?.inFlightRange = nil + } } } } func invalidateCache() { + prefetchTask?.cancel() + prefetchTask = nil + inFlightRange = nil cache.removeAll() isInitialized = false } diff --git a/TablePro/Views/Editor/SQLEditorCoordinator.swift b/TablePro/Views/Editor/SQLEditorCoordinator.swift index bbe0ec1f..f3c36e73 100644 --- a/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -295,7 +295,8 @@ final class SQLEditorCoordinator: TextViewCoordinator { ) { [weak self, weak controller] _ in guard let self, let controller else { return } // Defer so it runs AFTER reloadUI() → styleTextView() - DispatchQueue.main.async { + DispatchQueue.main.async { [weak self, weak controller] in + guard let self, let controller else { return } self.setHorizontalScrollProperties(controller: controller) self.handleVimSettingsChange(controller: controller) self.vimCursorManager?.updatePosition() diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 62c04409..3a3727f6 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -270,12 +270,12 @@ final class MainContentCoordinator { } } - registerForPersistence() _ = Self.registerTerminationObserver } func markActivated() { _didActivate.withLock { $0 = true } + registerForPersistence() setupPluginDriver() // Retry when driver becomes available (connection may still be in progress) if changeManager.pluginDriver == nil { @@ -366,6 +366,7 @@ 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() } if !alreadyHandled { Task { @MainActor in SchemaProviderRegistry.shared.release(for: connectionId) diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index f33957e2..771855e5 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -296,7 +296,7 @@ struct MainContentView: View { .onChange(of: currentTab?.resultColumns) { _, newColumns in handleColumnsChange(newColumns: newColumns) } - .onChange(of: DatabaseManager.shared.connectionStatusVersion, initial: true) { _, _ in + .onChange(of: DatabaseManager.shared.connectionStatusVersions[connection.id], initial: true) { _, _ in let sessions = DatabaseManager.shared.activeSessions guard let session = sessions[connection.id] else { return } if session.isConnected && coordinator.needsLazyLoad { diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index e85e1ba4..bdd73732 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -471,8 +471,26 @@ struct DataGridView: NSViewRepresentable { if needsFullReload { tableView.reloadData() } else if metadataChanged { - // FK metadata arrived (Phase 2) — reload all cells to show FK arrow buttons - tableView.reloadData() + // FK metadata arrived (Phase 2) — reload only FK columns to show arrow buttons. + // Use display-order indices from tableView.tableColumns (respects user column reordering). + let fkColumnIndices = IndexSet( + tableView.tableColumns.enumerated().compactMap { displayIndex, tableColumn in + guard tableColumn.identifier.rawValue != "__rowNumber__", + let modelIndex = Self.columnIndex(from: tableColumn.identifier), + modelIndex < rowProvider.columns.count else { return nil } + let columnName = rowProvider.columns[modelIndex] + return rowProvider.columnForeignKeys[columnName] != nil ? displayIndex : nil + } + ) + if !fkColumnIndices.isEmpty { + let visibleRange = tableView.rows(in: tableView.visibleRect) + if visibleRange.length > 0 { + let visibleRows = IndexSet( + integersIn: visibleRange.location..<(visibleRange.location + visibleRange.length) + ) + tableView.reloadData(forRowIndexes: visibleRows, columnIndexes: fkColumnIndices) + } + } } else if versionChanged { // Granular reload: only reload rows that changed let changedRows = changeManager.consumeChangedRowIndices() diff --git a/TablePro/Views/Results/JSONEditorContentView.swift b/TablePro/Views/Results/JSONEditorContentView.swift index f5610bfa..af03c547 100644 --- a/TablePro/Views/Results/JSONEditorContentView.swift +++ b/TablePro/Views/Results/JSONEditorContentView.swift @@ -195,17 +195,30 @@ private struct JSONSyntaxTextView: NSViewRepresentable { final class Coordinator: NSObject, NSTextViewDelegate { var parent: JSONSyntaxTextView var isUpdating = false + private var highlightWorkItem: DispatchWorkItem? init(_ parent: JSONSyntaxTextView) { self.parent = parent } + deinit { + highlightWorkItem?.cancel() + } + func textDidChange(_ notification: Notification) { guard let textView = notification.object as? NSTextView else { return } isUpdating = true parent.text = textView.string - JSONSyntaxTextView.applyHighlighting(to: textView) isUpdating = false + + // Debounce syntax highlighting to avoid 4 regex passes per keystroke + highlightWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak textView] in + guard let textView else { return } + JSONSyntaxTextView.applyHighlighting(to: textView) + } + highlightWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: workItem) } } }