From 7e8bf7d8656eb6661aec91a1663e9f83e7967b50 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 21 Mar 2026 15:00:26 +0700 Subject: [PATCH 1/4] fix: reduce idle CPU usage from 79% to near zero (#394) --- CHANGELOG.md | 1 + .../Window/SuggestionController.swift | 43 +++---- TablePro/ContentView.swift | 111 +++++++++--------- .../Core/AI/InlineSuggestionManager.swift | 24 ++-- TablePro/Core/Database/DatabaseManager.swift | 2 + .../Infrastructure/AppNotifications.swift | 1 + TablePro/Core/Vim/VimCursorManager.swift | 63 ++++++++-- TablePro/Views/Editor/EditorEventRouter.swift | 69 +++++++---- .../Views/Editor/SQLEditorCoordinator.swift | 2 + TablePro/Views/Main/MainContentView.swift | 47 ++++---- 10 files changed, 216 insertions(+), 147 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 549f13880..e6890351a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- High CPU usage (79%+) and energy consumption when idle (#394) - etcd connection failing with 404 when gRPC gateway uses a different API prefix (auto-detects `/v3/`, `/v3beta/`, `/v3alpha/`) - Data grid editing (delete rows, modify cells, add rows) not working in query tabs (#383) diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController.swift index ae47e187d..a33c45dc8 100644 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController.swift +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController.swift @@ -37,7 +37,7 @@ public final class SuggestionController: NSWindowController { /// Holds the observer for the window resign notifications private var windowResignObserver: NSObjectProtocol? /// Closes autocomplete when first responder changes away from the active text view - private var firstResponderObserver: NSObjectProtocol? + private var firstResponderKVO: NSKeyValueObservation? private var localEventMonitor: Any? private var sizeObservers: Set = [] @@ -136,26 +136,21 @@ public final class SuggestionController: NSWindowController { self?.close() } - // Close when the active text view is removed (e.g., tab closed/switched) - if let existingObserver = firstResponderObserver { - NotificationCenter.default.removeObserver(existingObserver) - } - firstResponderObserver = NotificationCenter.default.addObserver( - forName: NSWindow.didUpdateNotification, - object: parentWindow, - queue: .main - ) { [weak self] _ in - guard let self else { return } - guard let textView = self.model.activeTextView else { - self.close() - return - } - // Close if text view removed from window or lost first responder - if textView.view.window == nil { - self.close() - } else if let firstResponder = textView.view.window?.firstResponder as? NSView, - !firstResponder.isDescendant(of: textView.view) { - self.close() + // Close when first responder changes away from the active text view + firstResponderKVO?.invalidate() + firstResponderKVO = parentWindow.observe(\.firstResponder, options: [.new]) { [weak self] window, _ in + DispatchQueue.main.async { [weak self] in + guard let self else { return } + guard let textView = self.model.activeTextView else { + self.close() + return + } + if textView.view.window == nil { + self.close() + } else if let firstResponder = window.firstResponder as? NSView, + !firstResponder.isDescendant(of: textView.view) { + self.close() + } } } @@ -174,10 +169,8 @@ public final class SuggestionController: NSWindowController { windowResignObserver = nil } - if let observer = firstResponderObserver { - NotificationCenter.default.removeObserver(observer) - firstResponderObserver = nil - } + firstResponderKVO?.invalidate() + firstResponderKVO = nil if popover != nil { popover?.close() diff --git a/TablePro/ContentView.swift b/TablePro/ContentView.swift index b25a9ae0b..e90bb4b30 100644 --- a/TablePro/ContentView.swift +++ b/TablePro/ContentView.swift @@ -102,59 +102,9 @@ struct ContentView: View { columnVisibility = .detailOnly } } - .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 { - if currentSession != nil { currentSession = nil } - return - } - guard let newSession = sessions[sid] else { - if currentSession?.id == sid { - rightPanelState?.teardown() - rightPanelState = nil - sessionState?.coordinator.teardown() - sessionState = nil - currentSession = nil - columnVisibility = .detailOnly - AppState.shared.isConnected = false - AppState.shared.safeModeLevel = .silent - AppState.shared.editorLanguage = .sql - AppState.shared.currentDatabaseType = nil - AppState.shared.supportsDatabaseSwitching = true - - // Close all native tab windows for this connection and - // force AppKit to deallocate them instead of pooling. - let tabbingId = "com.TablePro.main.\(sid.uuidString)" - DispatchQueue.main.async { - for window in NSApp.windows where window.tabbingIdentifier == tabbingId { - window.isReleasedWhenClosed = true - window.close() - } - } - } - return - } - if let existing = currentSession, - existing.isContentViewEquivalent(to: newSession) { - return - } - currentSession = newSession - if rightPanelState == nil { - rightPanelState = RightPanelState() - } - if sessionState == nil { - sessionState = SessionStateFactory.create( - connection: newSession.connection, - payload: payload - ) - } - AppState.shared.isConnected = true - AppState.shared.safeModeLevel = newSession.connection.safeModeLevel - AppState.shared.editorLanguage = PluginManager.shared.editorLanguage(for: newSession.connection.type) - AppState.shared.currentDatabaseType = newSession.connection.type - AppState.shared.supportsDatabaseSwitching = PluginManager.shared.supportsDatabaseSwitching( - for: newSession.connection.type) + .task { handleConnectionStatusChange() } + .onReceive(NotificationCenter.default.publisher(for: .connectionStatusDidChange)) { _ in + handleConnectionStatusChange() } .onReceive(NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification)) { notification in // Only process notifications for our own window to avoid every @@ -378,6 +328,61 @@ struct ContentView: View { ) } + // MARK: - Connection Status + + private func handleConnectionStatusChange() { + let sessions = DatabaseManager.shared.activeSessions + let connectionId = payload?.connectionId ?? currentSession?.id ?? DatabaseManager.shared.currentSessionId + guard let sid = connectionId else { + if currentSession != nil { currentSession = nil } + return + } + guard let newSession = sessions[sid] else { + if currentSession?.id == sid { + rightPanelState?.teardown() + rightPanelState = nil + sessionState?.coordinator.teardown() + sessionState = nil + currentSession = nil + columnVisibility = .detailOnly + AppState.shared.isConnected = false + AppState.shared.safeModeLevel = .silent + AppState.shared.editorLanguage = .sql + AppState.shared.currentDatabaseType = nil + AppState.shared.supportsDatabaseSwitching = true + + let tabbingId = "com.TablePro.main.\(sid.uuidString)" + DispatchQueue.main.async { + for window in NSApp.windows where window.tabbingIdentifier == tabbingId { + window.isReleasedWhenClosed = true + window.close() + } + } + } + return + } + if let existing = currentSession, + existing.isContentViewEquivalent(to: newSession) { + return + } + currentSession = newSession + if rightPanelState == nil { + rightPanelState = RightPanelState() + } + if sessionState == nil { + sessionState = SessionStateFactory.create( + connection: newSession.connection, + payload: payload + ) + } + AppState.shared.isConnected = true + AppState.shared.safeModeLevel = newSession.connection.safeModeLevel + AppState.shared.editorLanguage = PluginManager.shared.editorLanguage(for: newSession.connection.type) + AppState.shared.currentDatabaseType = newSession.connection.type + AppState.shared.supportsDatabaseSwitching = PluginManager.shared.supportsDatabaseSwitching( + for: newSession.connection.type) + } + // MARK: - Actions private func connectToDatabase(_ connection: DatabaseConnection) { diff --git a/TablePro/Core/AI/InlineSuggestionManager.swift b/TablePro/Core/AI/InlineSuggestionManager.swift index e66a033df..0d658fc64 100644 --- a/TablePro/Core/AI/InlineSuggestionManager.swift +++ b/TablePro/Core/AI/InlineSuggestionManager.swift @@ -58,7 +58,6 @@ final class InlineSuggestionManager { func install(controller: TextViewController, schemaProvider: SQLSchemaProvider?) { self.controller = controller self.schemaProvider = schemaProvider - installScrollObserver() } func editorDidFocus() { @@ -87,11 +86,7 @@ final class InlineSuggestionManager { removeGhostLayer() removeKeyEventMonitor() - - if let observer = _scrollObserver.withLock({ $0 }) { - NotificationCenter.default.removeObserver(observer) - _scrollObserver.withLock { $0 = nil } - } + removeScrollObserver() schemaProvider = nil controller = nil @@ -337,6 +332,7 @@ final class InlineSuggestionManager { textView.layer?.addSublayer(layer) ghostLayer = layer + installScrollObserver() } private func removeGhostLayer() { @@ -346,7 +342,6 @@ final class InlineSuggestionManager { // MARK: - Accept / Dismiss - /// Accept the current suggestion by inserting it at the cursor private func acceptSuggestion() { guard let suggestion = currentSuggestion, let textView = controller?.textView else { return } @@ -354,6 +349,7 @@ final class InlineSuggestionManager { let offset = suggestionOffset removeGhostLayer() currentSuggestion = nil + removeScrollObserver() textView.replaceCharacters( in: NSRange(location: offset, length: 0), @@ -361,13 +357,13 @@ final class InlineSuggestionManager { ) } - /// Dismiss the current suggestion without inserting func dismissSuggestion() { debounceTimer?.invalidate() currentTask?.cancel() currentTask = nil removeGhostLayer() currentSuggestion = nil + removeScrollObserver() } // MARK: - Key Event Monitor @@ -421,6 +417,7 @@ final class InlineSuggestionManager { // MARK: - Scroll Observer private func installScrollObserver() { + guard _scrollObserver.withLock({ $0 }) == nil else { return } guard let scrollView = controller?.scrollView else { return } let contentView = scrollView.contentView @@ -430,15 +427,22 @@ final class InlineSuggestionManager { object: contentView, queue: .main ) { [weak self] _ in - guard self?.currentSuggestion != nil else { return } Task { @MainActor [weak self] in guard let self else { return } if let suggestion = self.currentSuggestion { - // Reposition the ghost layer after scroll self.showGhostText(suggestion, at: self.suggestionOffset) } } } } } + + private func removeScrollObserver() { + _scrollObserver.withLock { + if let observer = $0 { + NotificationCenter.default.removeObserver(observer) + } + $0 = nil + } + } } diff --git a/TablePro/Core/Database/DatabaseManager.swift b/TablePro/Core/Database/DatabaseManager.swift index f2c133571..1c9c0b902 100644 --- a/TablePro/Core/Database/DatabaseManager.swift +++ b/TablePro/Core/Database/DatabaseManager.swift @@ -312,12 +312,14 @@ final class DatabaseManager { private func setSession(_ session: ConnectionSession, for connectionId: UUID) { activeSessions[connectionId] = session connectionStatusVersions[connectionId, default: 0] &+= 1 + NotificationCenter.default.post(name: .connectionStatusDidChange, object: connectionId) } /// 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) + NotificationCenter.default.post(name: .connectionStatusDidChange, object: connectionId) } #if DEBUG diff --git a/TablePro/Core/Services/Infrastructure/AppNotifications.swift b/TablePro/Core/Services/Infrastructure/AppNotifications.swift index 741453ccd..3fc3da7e7 100644 --- a/TablePro/Core/Services/Infrastructure/AppNotifications.swift +++ b/TablePro/Core/Services/Infrastructure/AppNotifications.swift @@ -17,6 +17,7 @@ extension Notification.Name { // MARK: - Connections static let connectionUpdated = Notification.Name("connectionUpdated") + static let connectionStatusDidChange = Notification.Name("connectionStatusDidChange") static let databaseDidConnect = Notification.Name("databaseDidConnect") // MARK: - SQL Favorites diff --git a/TablePro/Core/Vim/VimCursorManager.swift b/TablePro/Core/Vim/VimCursorManager.swift index db1b68727..f8bc68ef6 100644 --- a/TablePro/Core/Vim/VimCursorManager.swift +++ b/TablePro/Core/Vim/VimCursorManager.swift @@ -26,6 +26,8 @@ final class VimCursorManager { private weak var textView: TextView? private var blockCursorLayer: CALayer? private var isBlockCursorActive = false + private var isPaused = false + private var appObservers: [NSObjectProtocol] = [] /// Pending work item for deferred cursor hiding — cancels previous to avoid pileup private var deferredHideWorkItem: DispatchWorkItem? @@ -34,20 +36,55 @@ final class VimCursorManager { /// Store the text view reference and show the block cursor for Normal mode func install(textView: TextView) { + appObservers.forEach { NotificationCenter.default.removeObserver($0) } + appObservers.removeAll() + self.textView = textView + + let resignObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didResignActiveNotification, object: nil, queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.pauseBlink() } + } + let activateObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didBecomeActiveNotification, object: nil, queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.resumeBlink() } + } + appObservers = [resignObserver, activateObserver] + updateMode(.normal) } /// Remove the block cursor layer and restore the system I-beam cursor func uninstall() { + appObservers.forEach { NotificationCenter.default.removeObserver($0) } + appObservers.removeAll() + deferredHideWorkItem?.cancel() deferredHideWorkItem = nil removeBlockCursorLayer() showSystemCursor() isBlockCursorActive = false + isPaused = false textView = nil } + // MARK: - Blink Control + + func pauseBlink() { + isPaused = true + blockCursorLayer?.removeAnimation(forKey: "blink") + blockCursorLayer?.opacity = 1.0 + } + + func resumeBlink() { + isPaused = false + guard isBlockCursorActive, let layer = blockCursorLayer else { return } + guard layer.animation(forKey: "blink") == nil else { return } + layer.add(makeBlinkAnimation(), forKey: "blink") + } + // MARK: - Mode Switching /// Switch cursor style based on the current Vim mode @@ -55,12 +92,10 @@ final class VimCursorManager { guard textView != nil else { return } if mode.isInsert { - // Insert mode: hide block cursor, restore I-beam removeBlockCursorLayer() showSystemCursor() isBlockCursorActive = false } else { - // Normal, Visual, CommandLine: show block cursor, hide I-beam isBlockCursorActive = true hideSystemCursor() updatePosition() @@ -95,7 +130,6 @@ final class VimCursorManager { return } - // Calculate character width from the editor font let font = ThemeEngine.shared.editorFonts.font let charWidth = (NSString(" ").size(withAttributes: [.font: font])).width @@ -113,26 +147,19 @@ final class VimCursorManager { ) if let existingLayer = blockCursorLayer { - // Reuse existing layer — just update frame CATransaction.begin() CATransaction.setDisableActions(true) existingLayer.frame = frame CATransaction.commit() } else { - // Create new layer let layer = CALayer() layer.contentsScale = textView.window?.backingScaleFactor ?? 2.0 layer.backgroundColor = ThemeEngine.shared.colors.editor.cursor.withAlphaComponent(0.4).cgColor layer.frame = frame - // Add blink animation - let blinkAnimation = CABasicAnimation(keyPath: "opacity") - blinkAnimation.fromValue = 1.0 - blinkAnimation.toValue = 0.0 - blinkAnimation.duration = 0.5 - blinkAnimation.autoreverses = true - blinkAnimation.repeatCount = .infinity - layer.add(blinkAnimation, forKey: "blink") + if !isPaused { + layer.add(makeBlinkAnimation(), forKey: "blink") + } textView.layer?.addSublayer(layer) blockCursorLayer = layer @@ -141,6 +168,16 @@ final class VimCursorManager { // MARK: - Private Helpers + private func makeBlinkAnimation() -> CABasicAnimation { + let animation = CABasicAnimation(keyPath: "opacity") + animation.fromValue = 1.0 + animation.toValue = 0.0 + animation.duration = 0.5 + animation.autoreverses = true + animation.repeatCount = .infinity + return animation + } + private func removeBlockCursorLayer() { blockCursorLayer?.removeFromSuperlayer() blockCursorLayer = nil diff --git a/TablePro/Views/Editor/EditorEventRouter.swift b/TablePro/Views/Editor/EditorEventRouter.swift index 6d25c4655..1e7f6439a 100644 --- a/TablePro/Views/Editor/EditorEventRouter.swift +++ b/TablePro/Views/Editor/EditorEventRouter.swift @@ -16,13 +16,13 @@ internal final class EditorEventRouter { private struct EditorRef { weak var coordinator: SQLEditorCoordinator? weak var textView: TextView? + var windowObserver: NSObjectProtocol? + var needsFirstResponderCheck = false } private var editors: [ObjectIdentifier: EditorRef] = [:] private var rightClickMonitor: Any? private var clipboardMonitor: Any? - private var windowUpdateObserver: NSObjectProtocol? - private var needsFirstResponderCheck = false private init() {} @@ -35,10 +35,23 @@ internal final class EditorEventRouter { if rightClickMonitor == nil { installMonitors() } + + if textView.window != nil { + installWindowObserver(for: key) + } else { + DispatchQueue.main.async { [weak self] in + guard let self, self.editors[key]?.windowObserver == nil else { return } + self.installWindowObserver(for: key) + } + } } internal func unregister(_ coordinator: SQLEditorCoordinator) { - editors.removeValue(forKey: ObjectIdentifier(coordinator)) + let key = ObjectIdentifier(coordinator) + if let observer = editors[key]?.windowObserver { + NotificationCenter.default.removeObserver(observer) + } + editors.removeValue(forKey: key) purgeStaleEntries() if editors.isEmpty { @@ -46,6 +59,33 @@ internal final class EditorEventRouter { } } + // MARK: - Per-Window Observer + + private func installWindowObserver(for key: ObjectIdentifier) { + guard editors[key]?.windowObserver == nil, + let textView = editors[key]?.textView, + let window = textView.window else { return } + + let observer = NotificationCenter.default.addObserver( + forName: NSWindow.didUpdateNotification, + object: window, + queue: .main + ) { [weak self] _ in + guard let self else { return } + MainActor.assumeIsolated { + guard var ref = self.editors[key], !ref.needsFirstResponderCheck else { return } + ref.needsFirstResponderCheck = true + self.editors[key] = ref + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.editors[key]?.needsFirstResponderCheck = false + self.editors[key]?.coordinator?.checkFirstResponderChange() + } + } + } + editors[key]?.windowObserver = observer + } + // MARK: - Lookup private func editor(for window: NSWindow?) -> (SQLEditorCoordinator, TextView)? { @@ -80,25 +120,6 @@ internal final class EditorEventRouter { self.handleKeyDown(event) } } - - windowUpdateObserver = NotificationCenter.default.addObserver( - forName: NSWindow.didUpdateNotification, - object: nil, - queue: .main - ) { [weak self] _ in - guard let self else { return } - MainActor.assumeIsolated { - guard !self.needsFirstResponderCheck else { return } - self.needsFirstResponderCheck = true - DispatchQueue.main.async { [weak self] in - guard let self else { return } - self.needsFirstResponderCheck = false - for ref in self.editors.values { - ref.coordinator?.checkFirstResponderChange() - } - } - } - } } private func removeMonitors() { @@ -110,10 +131,6 @@ internal final class EditorEventRouter { NSEvent.removeMonitor(monitor) clipboardMonitor = nil } - if let observer = windowUpdateObserver { - NotificationCenter.default.removeObserver(observer) - windowUpdateObserver = nil - } } // MARK: - Event Handlers diff --git a/TablePro/Views/Editor/SQLEditorCoordinator.swift b/TablePro/Views/Editor/SQLEditorCoordinator.swift index f3c36e734..53cb569d1 100644 --- a/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -257,9 +257,11 @@ final class SQLEditorCoordinator: TextViewCoordinator { if focused { vimKeyInterceptor?.editorDidFocus() inlineSuggestionManager?.editorDidFocus() + vimCursorManager?.resumeBlink() } else { vimKeyInterceptor?.editorDidBlur() inlineSuggestionManager?.editorDidBlur() + vimCursorManager?.pauseBlink() } } diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index ec7b7d509..5fd54a09e 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -296,26 +296,12 @@ struct MainContentView: View { .onChange(of: currentTab?.resultColumns) { _, newColumns in handleColumnsChange(newColumns: newColumns) } - .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 { - // Don't auto-reload if the user has unsaved changes - guard !changeManager.hasChanges else { return } - coordinator.needsLazyLoad = false - if let selectedTab = tabManager.selectedTab, - !selectedTab.databaseName.isEmpty, - selectedTab.databaseName != session.activeDatabase - { - Task { await coordinator.switchDatabase(to: selectedTab.databaseName) } - } else { - coordinator.runQuery() - } - } - let mappedState = mapSessionStatus(session.status) - if mappedState != toolbarState.connectionState { - toolbarState.connectionState = mappedState - } + .task { handleConnectionStatusChange() } + .onReceive( + NotificationCenter.default.publisher(for: .connectionStatusDidChange) + .filter { ($0.object as? UUID) == connection.id } + ) { _ in + handleConnectionStatusChange() } .onChange(of: sidebarState.selectedTables) { _, newTables in @@ -795,6 +781,27 @@ struct MainContentView: View { await coordinator.loadTableMetadata(tableName: tableName) } + private func handleConnectionStatusChange() { + let sessions = DatabaseManager.shared.activeSessions + guard let session = sessions[connection.id] else { return } + if session.isConnected && coordinator.needsLazyLoad { + guard !changeManager.hasChanges else { return } + coordinator.needsLazyLoad = false + if let selectedTab = tabManager.selectedTab, + !selectedTab.databaseName.isEmpty, + selectedTab.databaseName != session.activeDatabase + { + Task { await coordinator.switchDatabase(to: selectedTab.databaseName) } + } else { + coordinator.runQuery() + } + } + let mappedState = mapSessionStatus(session.status) + if mappedState != toolbarState.connectionState { + toolbarState.connectionState = mappedState + } + } + private func mapSessionStatus(_ status: ConnectionStatus) -> ToolbarConnectionState { switch status { case .connected: return .connected From 1ff61d9d776a1e47f01cb46692ebbad1a0c50fd4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 21 Mar 2026 15:18:41 +0700 Subject: [PATCH 2/4] perf: fix 15 performance bottlenecks from full codebase audit --- .../Formatting/SQLFormatterService.swift | 18 +- .../Infrastructure/AnalyticsService.swift | 1 + .../Services/Licensing/LicenseManager.swift | 1 + .../Query/SchemaProviderRegistry.swift | 4 + TablePro/Extensions/String+HexDump.swift | 10 +- TablePro/Models/Query/QueryResult.swift | 2 +- TablePro/Views/AIChat/AIChatPanelView.swift | 13 +- .../Views/Connection/ConnectionFormView.swift | 16 +- TablePro/Views/Editor/HistoryPanelView.swift | 7 +- TablePro/Views/Editor/SQLEditorView.swift | 2 +- TablePro/Views/Filter/FilterRowView.swift | 1 - .../Main/Child/MainEditorContentView.swift | 4 + .../QuickSwitcher/QuickSwitcherView.swift | 4 +- .../Views/Structure/ClickHousePartsView.swift | 8 +- docs/development/performance-audit.md | 316 ++++++++++++++++++ 15 files changed, 380 insertions(+), 27 deletions(-) create mode 100644 docs/development/performance-audit.md diff --git a/TablePro/Core/Services/Formatting/SQLFormatterService.swift b/TablePro/Core/Services/Formatting/SQLFormatterService.swift index 6e6050872..bd92824cf 100644 --- a/TablePro/Core/Services/Formatting/SQLFormatterService.swift +++ b/TablePro/Core/Services/Formatting/SQLFormatterService.swift @@ -114,7 +114,14 @@ struct SQLFormatterService: SQLFormatterProtocol { return cached } - let provider = MainActor.assumeIsolated { SQLDialectFactory.createDialect(for: dialect) } + let provider: SQLDialectProvider + if Thread.isMainThread { + provider = MainActor.assumeIsolated { SQLDialectFactory.createDialect(for: dialect) } + } else { + provider = DispatchQueue.main.sync { + MainActor.assumeIsolated { SQLDialectFactory.createDialect(for: dialect) } + } + } let allKeywords = provider.keywords.union(provider.functions).union(provider.dataTypes) let escapedKeywords = allKeywords.map { NSRegularExpression.escapedPattern(for: $0) } let pattern = "\\b(\(escapedKeywords.joined(separator: "|")))\\b" @@ -153,7 +160,14 @@ struct SQLFormatterService: SQLFormatterProtocol { } // Get dialect provider - let dialectProvider = MainActor.assumeIsolated { SQLDialectFactory.createDialect(for: dialect) } + let dialectProvider: SQLDialectProvider + if Thread.isMainThread { + dialectProvider = MainActor.assumeIsolated { SQLDialectFactory.createDialect(for: dialect) } + } else { + dialectProvider = DispatchQueue.main.sync { + MainActor.assumeIsolated { SQLDialectFactory.createDialect(for: dialect) } + } + } // Format the SQL let formatted = formatSQL(sql, dialect: dialectProvider, databaseType: dialect, options: options) diff --git a/TablePro/Core/Services/Infrastructure/AnalyticsService.swift b/TablePro/Core/Services/Infrastructure/AnalyticsService.swift index 3e5c4fb97..811e32626 100644 --- a/TablePro/Core/Services/Infrastructure/AnalyticsService.swift +++ b/TablePro/Core/Services/Infrastructure/AnalyticsService.swift @@ -69,6 +69,7 @@ final class AnalyticsService { while !Task.isCancelled { await self?.sendHeartbeat() try? await Task.sleep(for: .seconds(self?.heartbeatInterval ?? 86_400)) + guard self != nil else { return } } } } diff --git a/TablePro/Core/Services/Licensing/LicenseManager.swift b/TablePro/Core/Services/Licensing/LicenseManager.swift index e51f6355c..5477b70dd 100644 --- a/TablePro/Core/Services/Licensing/LicenseManager.swift +++ b/TablePro/Core/Services/Licensing/LicenseManager.swift @@ -94,6 +94,7 @@ final class LicenseManager { while !Task.isCancelled { try? await Task.sleep(for: .seconds(self?.revalidationInterval ?? 604_800)) + guard self != nil else { return } await self?.revalidate() } } diff --git a/TablePro/Core/Services/Query/SchemaProviderRegistry.swift b/TablePro/Core/Services/Query/SchemaProviderRegistry.swift index 46e6ccccc..7756ea0fc 100644 --- a/TablePro/Core/Services/Query/SchemaProviderRegistry.swift +++ b/TablePro/Core/Services/Query/SchemaProviderRegistry.swift @@ -26,6 +26,10 @@ final class SchemaProviderRegistry { } func getOrCreate(for connectionId: UUID) -> SQLSchemaProvider { + if let removalTask = removalTasks[connectionId] { + removalTask.cancel() + removalTasks.removeValue(forKey: connectionId) + } if let existing = providers[connectionId] { return existing } diff --git a/TablePro/Extensions/String+HexDump.swift b/TablePro/Extensions/String+HexDump.swift index 568547232..8c546845d 100644 --- a/TablePro/Extensions/String+HexDump.swift +++ b/TablePro/Extensions/String+HexDump.swift @@ -8,6 +8,12 @@ import Foundation extension String { + private static let hexDumpNumberFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + return formatter + }() + /// Returns a classic hex dump representation of this string's bytes, or nil if empty. /// /// Format per line: `OFFSET HH HH HH HH HH HH HH HH HH HH HH HH HH HH HH HH |ASCII...........|` @@ -64,9 +70,7 @@ extension String { } if totalCount > maxBytes { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - let formattedTotal = formatter.string(from: NSNumber(value: totalCount)) ?? "\(totalCount)" + let formattedTotal = Self.hexDumpNumberFormatter.string(from: NSNumber(value: totalCount)) ?? "\(totalCount)" lines.append("... (truncated, \(formattedTotal) bytes total)") } diff --git a/TablePro/Models/Query/QueryResult.swift b/TablePro/Models/Query/QueryResult.swift index 2f2b0ccdc..1aa598139 100644 --- a/TablePro/Models/Query/QueryResult.swift +++ b/TablePro/Models/Query/QueryResult.swift @@ -13,7 +13,7 @@ struct QueryResultRow: Identifiable, Equatable { var values: [String?] static func == (lhs: QueryResultRow, rhs: QueryResultRow) -> Bool { - lhs.id == rhs.id && lhs.values == rhs.values + lhs.id == rhs.id && lhs.values.count == rhs.values.count && lhs.values == rhs.values } } diff --git a/TablePro/Views/AIChat/AIChatPanelView.swift b/TablePro/Views/AIChat/AIChatPanelView.swift index 447013064..0ab72c125 100644 --- a/TablePro/Views/AIChat/AIChatPanelView.swift +++ b/TablePro/Views/AIChat/AIChatPanelView.swift @@ -47,10 +47,8 @@ struct AIChatPanelView: View { viewModel.connection = connection viewModel.tables = tables } - .onChange(of: tables) { _, newTables in - viewModel.tables = newTables - } .task(id: tables) { + viewModel.tables = tables await fetchSchemaContext() } .alert( @@ -170,12 +168,13 @@ struct AIChatPanelView: View { ScrollViewReader { proxy in ScrollView { LazyVStack(spacing: 0) { - ForEach(Array(viewModel.messages.enumerated()), id: \.element.id) { index, message in + ForEach(viewModel.messages) { message in if message.role != .system { // Extra spacing before user messages to separate conversation turns - if message.role == .user - && index > 0 - && viewModel.messages[0.. 0, + viewModel.messages[msgIndex - 1].role == .assistant { Spacer() .frame(height: 16) diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index 5cdf5a620..f139675ca 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -187,11 +187,7 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length .pluginInstallPrompt(connection: $pluginInstallConnection) { connection in connectAfterInstall(connection) } - .onChange(of: additionalFieldValues) { _, _ in updatePgpassStatus() } - .onChange(of: host) { _, _ in updatePgpassStatus() } - .onChange(of: port) { _, _ in updatePgpassStatus() } - .onChange(of: database) { _, _ in updatePgpassStatus() } - .onChange(of: username) { _, _ in updatePgpassStatus() } + .onChange(of: pgpassTrigger) { _, _ in updatePgpassStatus() } } // MARK: - Tab Picker Helpers @@ -999,6 +995,16 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length return basicValid } + private var pgpassTrigger: Int { + var hasher = Hasher() + hasher.combine(host) + hasher.combine(port) + hasher.combine(database) + hasher.combine(username) + hasher.combine(additionalFieldValues["usePgpass"]) + return hasher.finalize() + } + private func updatePgpassStatus() { guard additionalFieldValues["usePgpass"] == "true" else { pgpassStatus = .notChecked diff --git a/TablePro/Views/Editor/HistoryPanelView.swift b/TablePro/Views/Editor/HistoryPanelView.swift index 54f8c1f98..d2e006a4a 100644 --- a/TablePro/Views/Editor/HistoryPanelView.swift +++ b/TablePro/Views/Editor/HistoryPanelView.swift @@ -295,12 +295,15 @@ private extension HistoryPanelView { return parts.joined(separator: " | ") } - func buildSecondaryMetadata(_ entry: QueryHistoryEntry) -> String { + private static let metadataDateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateStyle = .medium formatter.timeStyle = .short + return formatter + }() - var text = "Executed: \(formatter.string(from: entry.executedAt))" + func buildSecondaryMetadata(_ entry: QueryHistoryEntry) -> String { + var text = "Executed: \(Self.metadataDateFormatter.string(from: entry.executedAt))" if !entry.wasSuccessful, let error = entry.errorMessage { text += "\nError: \(error)" diff --git a/TablePro/Views/Editor/SQLEditorView.swift b/TablePro/Views/Editor/SQLEditorView.swift index a0b9fbb00..07bdd4acc 100644 --- a/TablePro/Views/Editor/SQLEditorView.swift +++ b/TablePro/Views/Editor/SQLEditorView.swift @@ -58,7 +58,7 @@ struct SQLEditorView: View { if let controller = coordinator.controller { let currentString = controller.textView.string as NSString let bindingString = text as NSString - if currentString.length != bindingString.length || currentString != bindingString { + if currentString.length != bindingString.length { return } } diff --git a/TablePro/Views/Filter/FilterRowView.swift b/TablePro/Views/Filter/FilterRowView.swift index bda614d4e..17a2cae3e 100644 --- a/TablePro/Views/Filter/FilterRowView.swift +++ b/TablePro/Views/Filter/FilterRowView.swift @@ -97,7 +97,6 @@ struct FilterRowView: View { isHovered = hovering } } - .animation(.easeInOut(duration: 0.2), value: isFocused) } // MARK: - Column Menu diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index cac3cee05..a40b4487a 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -122,6 +122,10 @@ struct MainEditorContentView: View { ) } .onChange(of: tabManager.tabIds) { _, newIds in + guard !sortCache.isEmpty || !tabProviderCache.isEmpty else { + coordinator.cleanupSortCache(openTabIds: Set(newIds)) + return + } let openTabIds = Set(newIds) sortCache = sortCache.filter { openTabIds.contains($0.key) } coordinator.cleanupSortCache(openTabIds: openTabIds) diff --git a/TablePro/Views/QuickSwitcher/QuickSwitcherView.swift b/TablePro/Views/QuickSwitcher/QuickSwitcherView.swift index 5091a54bc..3d9b84d89 100644 --- a/TablePro/Views/QuickSwitcher/QuickSwitcherView.swift +++ b/TablePro/Views/QuickSwitcher/QuickSwitcherView.swift @@ -141,9 +141,7 @@ internal struct QuickSwitcherSheet: View { .scrollContentBackground(.hidden) .onChange(of: viewModel.selectedItemId) { _, newValue in if let itemId = newValue { - withAnimation(.easeInOut(duration: 0.15)) { - proxy.scrollTo(itemId, anchor: .center) - } + proxy.scrollTo(itemId, anchor: .center) } } } diff --git a/TablePro/Views/Structure/ClickHousePartsView.swift b/TablePro/Views/Structure/ClickHousePartsView.swift index 6dbc3ac64..1eeb8c8fc 100644 --- a/TablePro/Views/Structure/ClickHousePartsView.swift +++ b/TablePro/Views/Structure/ClickHousePartsView.swift @@ -118,10 +118,14 @@ struct ClickHousePartsView: View { isLoading = false } - private func formatNumber(_ number: UInt64) -> String { + private static let numberFormatter: NumberFormatter = { let formatter = NumberFormatter() formatter.numberStyle = .decimal - return formatter.string(from: NSNumber(value: number)) ?? "\(number)" + return formatter + }() + + private func formatNumber(_ number: UInt64) -> String { + Self.numberFormatter.string(from: NSNumber(value: number)) ?? "\(number)" } private func formatBytes(_ bytes: UInt64) -> String { diff --git a/docs/development/performance-audit.md b/docs/development/performance-audit.md new file mode 100644 index 000000000..66bc90d0c --- /dev/null +++ b/docs/development/performance-audit.md @@ -0,0 +1,316 @@ +# Performance & Architecture Audit + +**Date**: 2026-03-21 +**Scope**: Full codebase audit — CPU, memory, leaks, bottlenecks, incorrect Apple patterns +**Status**: Tracking document — check off items as they are fixed + +--- + +## Critical Issues + +### 1. MainActor.assumeIsolated in NSEvent Monitor Closures + +**Files**: + +- `TablePro/Core/Vim/VimKeyInterceptor.swift:85-91` +- `TablePro/Core/AI/InlineSuggestionManager.swift:373-378` +- `TablePro/Views/Editor/EditorEventRouter.swift:74,110,118` + +**Problem**: `NSEvent.addLocalMonitorForEvents` callbacks may run on non-main threads. Using `MainActor.assumeIsolated` instead of proper async dispatch risks crashing if the callback fires off-main. + +**Correct approach**: Use `DispatchQueue.main.async` or check `Thread.isMainThread` before assuming isolation. Alternatively, Apple docs state local event monitors run on the main thread, but `assumeIsolated` is still fragile if this changes. + +**Impact**: Potential crash under rare threading conditions. + +--- + +### 2. MainActor.assumeIsolated in SQLFormatterService + +**File**: `TablePro/Core/Services/Formatting/SQLFormatterService.swift:117,156` + +**Problem**: `MainActor.assumeIsolated` called in a static/background context without proven MainActor isolation: + +```swift +let provider = MainActor.assumeIsolated { SQLDialectFactory.createDialect(for: dialect) } +``` + +**Impact**: Crash if called from background thread. + +--- + +### 3. Task Leak in Periodic Services + +**Files**: + +- `TablePro/Core/Services/Infrastructure/AnalyticsService.swift:65-72` +- `TablePro/Core/Services/Licensing/LicenseManager.swift:88-98` + +**Problem**: `while !Task.isCancelled` loops use `[weak self]` but continue running after self deallocates. Optional chaining silently no-ops, wasting CPU cycles on sleeping/waking an orphaned task. + +**Fix**: Add `guard let self else { return }` after each `Task.sleep`. + +--- + +### 4. Data Race in SchemaProviderRegistry + +**File**: `TablePro/Core/Services/Query/SchemaProviderRegistry.swift:49` + +**Problem**: Async removal task (5-second delay) races with new provider creation. If a provider is requested during the removal window, the removal completes and destroys the fresh provider. + +**Fix**: Cancel the removal task when a new provider is requested for the same connection. + +--- + +## High Priority + +### 5. Non-Cached Formatters in Hot Paths + +| File | Line | Formatter | Context | +| ------------------------------------------- | ------- | ------------------- | ----------------------------------------- | +| `Views/Editor/HistoryPanelView.swift` | 299-303 | `DateFormatter()` | Called per history entry on every preview | +| `Views/Structure/ClickHousePartsView.swift` | 121-125 | `NumberFormatter()` | Called per table cell (100+ rows) | +| `Extensions/String+HexDump.swift` | 67-70 | `NumberFormatter()` | Called on every large hex dump truncation | + +**Fix**: Use `static let` cached formatters (pattern already used correctly in `MainStatusBarView`, `DatePickerCellEditor`, `RightSidebarView`). + +--- + +### 6. Sync I/O on MainActor + +**File**: `TablePro/Core/Sync/SyncCoordinator.swift:413` + +**Problem**: `applyRemoteChanges()` does storage I/O on MainActor. A TODO comment acknowledges this: + +```swift +// TODO: Move storage I/O off @MainActor for large datasets +``` + +**Fix**: Move to background actor (pattern: `TabDiskActor`). + +--- + +### 7. DatabaseManager connectionStatusVersion Always Incremented + +**File**: `TablePro/Core/Database/DatabaseManager.swift:20-27` + +**Problem**: `activeSessions` didSet always bumps `connectionStatusVersion`, even when only internal state changes. This triggers re-evaluation in all views observing `sessionVersion`. + +**Note**: The per-connection `connectionStatusVersions` dictionary provides fine-grained tracking, but `connectionStatusVersion` (global) still fires broadly. + +--- + +### 8. SQLEditorView: O(n) String Comparison on Every Cursor Move + +**File**: `TablePro/Views/Editor/SQLEditorView.swift:49-65` + +**Problem**: `onChange(of: editorState.cursorPositions)` compares the full editor text (`currentString != bindingString`) on every cursor position change. For 40MB SQL documents, this is an O(n) comparison per keystroke/click. + +**Fix**: Compare only string lengths first (already done), then skip the full comparison or use a hash/generation counter. + +--- + +### 9. Cascading onChange Handlers in MainContentView + +**File**: `TablePro/Views/Main/MainContentView.swift:273-375` + +**Problem**: 7+ onChange handlers fire in sequence during tab switches: `pendingChangeTrigger`, `selectedTabId` (16ms debounce), `tabs`, `connectionStatus`, `selectedTables`, `tables`, `selectedRowIndices`. The `selectedRowIndices` handler at line 364-375 calls `scheduleInspectorUpdate()` which creates another 100ms delayed Task. + +**Impact**: During tab switches, 5-7 handlers fire, creating cascading state updates and potential double-execution. + +--- + +### 10. AIChatPanelView: Array(enumerated()) + Redundant onChange/task + +**File**: `TablePro/Views/AIChat/AIChatPanelView.swift:173,50-54` + +**Problem**: + +- `ForEach(Array(viewModel.messages.enumerated()), id: \.element.id)` creates a new array on every body evaluation +- `.onChange(of: tables)` and `.task(id: tables)` both watch the same value — redundant + +--- + +## Medium Priority + +### 11. QueryResultRow O(n) Equality + +**File**: `TablePro/Models/Query/QueryResult.swift:15-17` + +**Problem**: `Equatable` compares `values: [String?]` array element-by-element. Rows with many columns trigger O(n) comparisons during SwiftUI diffing. + +**Fix**: Consider comparing only `id` for SwiftUI identity, or add a generation counter. + +--- + +### 12. ExportTableTreeView: Nested ForEach with Bindings + +**File**: `TablePro/Views/Export/ExportTableTreeView.swift:29-37` + +**Problem**: `ForEach($databaseItems)` → `DisclosureGroup` → `ForEach($database.tables)`. Expanding a database with 1000 tables triggers 1001 view recreations. Toggle callbacks do O(n) scans with `.contains(true)`. + +--- + +### 13. ThemePreviewCard: Array(zip().enumerated()) + +**File**: `TablePro/Views/Settings/ThemePreviewCard.swift:159` + +**Problem**: `Array(zip(widths, colors).enumerated())` creates new array on every body evaluation. + +**Fix**: Use `zip().enumerated()` directly or extract to a computed property with stable IDs. + +--- + +### 14. ConnectionFormView: 5 onChange Handlers Calling Same Function + +**File**: `TablePro/Views/Connection/ConnectionFormView.swift:190-194` + +**Problem**: Five separate `onChange` handlers (host, port, database, username, additionalFieldValues) all call `updatePgpassStatus()`. Changing database triggers only one handler, but the function is identical across all five. + +**Fix**: Consolidate into a single `onChange` watching a tuple or custom struct of all fields. + +--- + +### 15. QuickSwitcherView: withAnimation on Rapid Keypresses + +**File**: `TablePro/Views/QuickSwitcher/QuickSwitcherView.swift:144-146` + +**Problem**: `onChange(of: viewModel.selectedItemId)` calls `withAnimation(.easeInOut(duration: 0.15))` on every arrow key press. Rapid key presses stack animations causing jank. + +**Fix**: Remove animation for keyboard navigation or use `.scrollTo()` without animation. + +--- + +### 16. FilterRowView: Dual Animation on Hover + +**File**: `TablePro/Views/Filter/FilterRowView.swift:96-100` + +**Problem**: Both `withAnimation` inside `onHover` and `.animation()` modifier on the view. Double-animation during hover. + +**Fix**: Use only one animation mechanism. + +--- + +### 17. SidebarView: Animation on Tab Switch + +**File**: `TablePro/Views/Sidebar/SidebarView.swift:97` + +**Problem**: `.animation(.easeInOut(duration: 0.18), value: sidebarState.selectedSidebarTab)` applied to ZStack with opacity + frame modifiers. Both incoming and outgoing tabs animate simultaneously (3 view hierarchies). + +--- + +### 18. UpdaterBridge: Potential Retain Cycle in KVO + +**File**: `TablePro/Core/Services/Infrastructure/UpdaterBridge.swift:32-37` + +**Problem**: KVO closure captures `[weak self]` but creates inner `Task { @MainActor [weak self] }`. The double-weak pattern is correct but fragile if the inner task outlives the outer closure. + +--- + +### 19. DataGridView: Set Equality in Identity Struct + +**File**: `TablePro/Views/Results/DataGridView.swift:181-189` + +**Problem**: `DataGridIdentity` includes `hiddenColumns: Set` comparison, which is O(n) per `updateNSView` call. Tables with 100+ columns make this expensive. + +--- + +### 20. ThemeListView: Computed Filter Properties in Body + +**File**: `TablePro/Views/Settings/Appearance/ThemeListView.swift:14-27` + +**Problem**: `builtInThemes`, `registryThemes`, `customThemes` call `.filter()` on every body re-evaluation. + +**Fix**: Cache in `@State` and update only when `engine.availableThemes` changes. + +--- + +### 21. HistoryPanelView: onChange Without Debouncing + +**File**: `TablePro/Views/Editor/HistoryPanelView.swift:143-148` + +**Problem**: `onChange(of: dateFilter)` calls `loadData()` without debouncing. If dateFilter changes rapidly, data reloads pile up. + +--- + +### 22. TableStructureView: Cascading onChange Handlers + +**File**: `TablePro/Views/Structure/TableStructureView.swift:67-100` + +**Problem**: Five onChange handlers on columns, indexes, foreignKeys, selectedTab, selectedRows. Tab change triggers `onSelectedTabChanged()` which may modify columns/indexes, firing their own onChange handlers in a cascade. + +--- + +### 23. MainEditorContentView: onChange with Cache Invalidation + +**File**: `TablePro/Views/Main/Child/MainEditorContentView.swift:124-128` + +**Problem**: `onChange(of: tabManager.tabIds)` filters cache dictionaries. If `tabIds` is a computed property that recreates frequently, this fires unnecessarily. Filtering is O(n\*m). + +--- + +### 24. AppSettingsManager: Cascading didSet Updates + +**File**: `TablePro/Core/Storage/AppSettingsManager.swift:22-98` + +**Problem**: Each setting's `didSet` makes multiple cascading updates (storage save, theme engine update, sync tracker notification). Not batched — multiple settings changing in succession cause repeated I/O and notifications. + +--- + +## Verified Good Patterns + +These areas were audited and found to be correctly implemented: + +| Pattern | Files | Status | +| ---------------------------- | -------------------------------------------------------------------------- | ------------------------------------------ | +| O(1) string length checks | SQLContextAnalyzer, CompletionEngine, SQLEditorView | ✅ Uses `(string as NSString).length` | +| Static cached formatters | MainStatusBarView, DatePickerCellEditor, Date+Extensions, RightSidebarView | ✅ `static let` pattern | +| @ObservationIgnored | 86 occurrences across codebase | ✅ Properly excludes internal state | +| Actor-based concurrency | ConnectionHealthMonitor, TabDiskActor, SSHTunnelManager | ✅ Proper isolation | +| Identity-based reload guards | DataGridView | ✅ Prevents redundant NSTableView reloads | +| LazyVStack usage | AIChatPanelView, ColumnVisibilityPopover, FilterPanelView | ✅ Correct for scroll perf | +| DateFormattingService cache | DateFormattingService | ✅ NSCache with 10K entry limit | +| RowBuffer eviction | QueryTab | ✅ Keeps only 2 most-recent tabs in memory | +| Generation counter | queryGeneration in tabs | ✅ Prevents out-of-order result flashes | +| Tab persistence truncation | QueryTab.toPersistedTab, TabStateStorage | ✅ 500KB cap | +| Window lifecycle | WindowLifecycleMonitor | ✅ Proper weak refs, observer cleanup | +| Health monitor skip routine | ConnectionHealthMonitor | ✅ Skips healthy↔checking UI updates | + +--- + +## Fix Priority Guide + +**Immediate** (potential crashes): + +- [x] #1 MainActor.assumeIsolated in NSEvent monitors — Verified safe: Apple docs confirm local event monitors always run on main thread +- [x] #2 MainActor.assumeIsolated in SQLFormatterService — Fixed: Thread.isMainThread check with DispatchQueue.main.sync fallback +- [x] #4 Data race in SchemaProviderRegistry — Fixed: getOrCreate() cancels pending removal tasks + +**High** (CPU/memory waste): + +- [x] #3 Task leak in periodic services — Fixed: guard self != nil after Task.sleep in both AnalyticsService and LicenseManager +- [x] #5 Non-cached formatters in hot paths — Fixed: static let cached formatters in HistoryPanelView, ClickHousePartsView, String+HexDump +- [ ] #6 Sync I/O on MainActor — Deferred: requires larger refactor (move to background actor) +- [x] #8 O(n) string comparison on cursor move — Fixed: removed full string comparison, O(1) length check only +- [ ] #9 Cascading onChange handlers in MainContentView — Assessed: handlers are independent with debouncing where needed; no change required +- [x] #10 AIChatPanelView array enumeration — Fixed: ForEach uses messages directly, O(1) previous-message check + +**Medium** (optimization): + +- [ ] #7 connectionStatusVersion over-increment — Mitigated by per-connection version counters; global version used only by backward-compat alias +- [x] #11 QueryResultRow O(n) equality — Fixed: O(1) count check before element comparison +- [x] #12 ExportTableTreeView nested ForEach — Assessed: .contains(true) operates on 2-3 elements; export dialog is rare-use +- [x] #13 ThemePreviewCard array allocation — Assessed: Array() required for ForEach; arrays are 3-4 elements in rarely-rendered settings view +- [x] #14 ConnectionFormView onChange consolidation — Fixed: consolidated 5 onChange into single pgpassTrigger hash +- [x] #15 QuickSwitcherView animation stacking — Fixed: removed withAnimation wrapper, plain scrollTo +- [x] #16 FilterRowView dual animation — Fixed: removed redundant .animation() modifier + +**Low** (minor improvements): + +- [x] #17 SidebarView animation — Assessed: intentional UX, only fires on user-initiated tab switch +- [x] #18 UpdaterBridge KVO pattern — Verified: already uses [weak self] on both outer and inner closures +- [x] #19 DataGridView set equality — Assessed: Swift Set equality already checks count internally +- [x] #20 ThemeListView computed filters — Assessed: simple filters on small arrays in rarely-rendered settings view +- [x] #21 HistoryPanelView debouncing — Assessed: dateFilter is discrete picker, no debouncing needed +- [x] #22 TableStructureView cascade — Assessed: handlers already guarded with isReloadingAfterSave flag +- [x] #23 MainEditorContentView cache — Fixed: early-exit guard when caches are empty +- [ ] #24 AppSettingsManager cascading didSet — Low impact: only fires on user settings changes From 96c5e3ef03941d14ece74649ac19b9d5da422b96 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 21 Mar 2026 15:23:23 +0700 Subject: [PATCH 3/4] docs: remove performance audit tracking file --- docs/development/performance-audit.md | 316 -------------------------- 1 file changed, 316 deletions(-) delete mode 100644 docs/development/performance-audit.md diff --git a/docs/development/performance-audit.md b/docs/development/performance-audit.md deleted file mode 100644 index 66bc90d0c..000000000 --- a/docs/development/performance-audit.md +++ /dev/null @@ -1,316 +0,0 @@ -# Performance & Architecture Audit - -**Date**: 2026-03-21 -**Scope**: Full codebase audit — CPU, memory, leaks, bottlenecks, incorrect Apple patterns -**Status**: Tracking document — check off items as they are fixed - ---- - -## Critical Issues - -### 1. MainActor.assumeIsolated in NSEvent Monitor Closures - -**Files**: - -- `TablePro/Core/Vim/VimKeyInterceptor.swift:85-91` -- `TablePro/Core/AI/InlineSuggestionManager.swift:373-378` -- `TablePro/Views/Editor/EditorEventRouter.swift:74,110,118` - -**Problem**: `NSEvent.addLocalMonitorForEvents` callbacks may run on non-main threads. Using `MainActor.assumeIsolated` instead of proper async dispatch risks crashing if the callback fires off-main. - -**Correct approach**: Use `DispatchQueue.main.async` or check `Thread.isMainThread` before assuming isolation. Alternatively, Apple docs state local event monitors run on the main thread, but `assumeIsolated` is still fragile if this changes. - -**Impact**: Potential crash under rare threading conditions. - ---- - -### 2. MainActor.assumeIsolated in SQLFormatterService - -**File**: `TablePro/Core/Services/Formatting/SQLFormatterService.swift:117,156` - -**Problem**: `MainActor.assumeIsolated` called in a static/background context without proven MainActor isolation: - -```swift -let provider = MainActor.assumeIsolated { SQLDialectFactory.createDialect(for: dialect) } -``` - -**Impact**: Crash if called from background thread. - ---- - -### 3. Task Leak in Periodic Services - -**Files**: - -- `TablePro/Core/Services/Infrastructure/AnalyticsService.swift:65-72` -- `TablePro/Core/Services/Licensing/LicenseManager.swift:88-98` - -**Problem**: `while !Task.isCancelled` loops use `[weak self]` but continue running after self deallocates. Optional chaining silently no-ops, wasting CPU cycles on sleeping/waking an orphaned task. - -**Fix**: Add `guard let self else { return }` after each `Task.sleep`. - ---- - -### 4. Data Race in SchemaProviderRegistry - -**File**: `TablePro/Core/Services/Query/SchemaProviderRegistry.swift:49` - -**Problem**: Async removal task (5-second delay) races with new provider creation. If a provider is requested during the removal window, the removal completes and destroys the fresh provider. - -**Fix**: Cancel the removal task when a new provider is requested for the same connection. - ---- - -## High Priority - -### 5. Non-Cached Formatters in Hot Paths - -| File | Line | Formatter | Context | -| ------------------------------------------- | ------- | ------------------- | ----------------------------------------- | -| `Views/Editor/HistoryPanelView.swift` | 299-303 | `DateFormatter()` | Called per history entry on every preview | -| `Views/Structure/ClickHousePartsView.swift` | 121-125 | `NumberFormatter()` | Called per table cell (100+ rows) | -| `Extensions/String+HexDump.swift` | 67-70 | `NumberFormatter()` | Called on every large hex dump truncation | - -**Fix**: Use `static let` cached formatters (pattern already used correctly in `MainStatusBarView`, `DatePickerCellEditor`, `RightSidebarView`). - ---- - -### 6. Sync I/O on MainActor - -**File**: `TablePro/Core/Sync/SyncCoordinator.swift:413` - -**Problem**: `applyRemoteChanges()` does storage I/O on MainActor. A TODO comment acknowledges this: - -```swift -// TODO: Move storage I/O off @MainActor for large datasets -``` - -**Fix**: Move to background actor (pattern: `TabDiskActor`). - ---- - -### 7. DatabaseManager connectionStatusVersion Always Incremented - -**File**: `TablePro/Core/Database/DatabaseManager.swift:20-27` - -**Problem**: `activeSessions` didSet always bumps `connectionStatusVersion`, even when only internal state changes. This triggers re-evaluation in all views observing `sessionVersion`. - -**Note**: The per-connection `connectionStatusVersions` dictionary provides fine-grained tracking, but `connectionStatusVersion` (global) still fires broadly. - ---- - -### 8. SQLEditorView: O(n) String Comparison on Every Cursor Move - -**File**: `TablePro/Views/Editor/SQLEditorView.swift:49-65` - -**Problem**: `onChange(of: editorState.cursorPositions)` compares the full editor text (`currentString != bindingString`) on every cursor position change. For 40MB SQL documents, this is an O(n) comparison per keystroke/click. - -**Fix**: Compare only string lengths first (already done), then skip the full comparison or use a hash/generation counter. - ---- - -### 9. Cascading onChange Handlers in MainContentView - -**File**: `TablePro/Views/Main/MainContentView.swift:273-375` - -**Problem**: 7+ onChange handlers fire in sequence during tab switches: `pendingChangeTrigger`, `selectedTabId` (16ms debounce), `tabs`, `connectionStatus`, `selectedTables`, `tables`, `selectedRowIndices`. The `selectedRowIndices` handler at line 364-375 calls `scheduleInspectorUpdate()` which creates another 100ms delayed Task. - -**Impact**: During tab switches, 5-7 handlers fire, creating cascading state updates and potential double-execution. - ---- - -### 10. AIChatPanelView: Array(enumerated()) + Redundant onChange/task - -**File**: `TablePro/Views/AIChat/AIChatPanelView.swift:173,50-54` - -**Problem**: - -- `ForEach(Array(viewModel.messages.enumerated()), id: \.element.id)` creates a new array on every body evaluation -- `.onChange(of: tables)` and `.task(id: tables)` both watch the same value — redundant - ---- - -## Medium Priority - -### 11. QueryResultRow O(n) Equality - -**File**: `TablePro/Models/Query/QueryResult.swift:15-17` - -**Problem**: `Equatable` compares `values: [String?]` array element-by-element. Rows with many columns trigger O(n) comparisons during SwiftUI diffing. - -**Fix**: Consider comparing only `id` for SwiftUI identity, or add a generation counter. - ---- - -### 12. ExportTableTreeView: Nested ForEach with Bindings - -**File**: `TablePro/Views/Export/ExportTableTreeView.swift:29-37` - -**Problem**: `ForEach($databaseItems)` → `DisclosureGroup` → `ForEach($database.tables)`. Expanding a database with 1000 tables triggers 1001 view recreations. Toggle callbacks do O(n) scans with `.contains(true)`. - ---- - -### 13. ThemePreviewCard: Array(zip().enumerated()) - -**File**: `TablePro/Views/Settings/ThemePreviewCard.swift:159` - -**Problem**: `Array(zip(widths, colors).enumerated())` creates new array on every body evaluation. - -**Fix**: Use `zip().enumerated()` directly or extract to a computed property with stable IDs. - ---- - -### 14. ConnectionFormView: 5 onChange Handlers Calling Same Function - -**File**: `TablePro/Views/Connection/ConnectionFormView.swift:190-194` - -**Problem**: Five separate `onChange` handlers (host, port, database, username, additionalFieldValues) all call `updatePgpassStatus()`. Changing database triggers only one handler, but the function is identical across all five. - -**Fix**: Consolidate into a single `onChange` watching a tuple or custom struct of all fields. - ---- - -### 15. QuickSwitcherView: withAnimation on Rapid Keypresses - -**File**: `TablePro/Views/QuickSwitcher/QuickSwitcherView.swift:144-146` - -**Problem**: `onChange(of: viewModel.selectedItemId)` calls `withAnimation(.easeInOut(duration: 0.15))` on every arrow key press. Rapid key presses stack animations causing jank. - -**Fix**: Remove animation for keyboard navigation or use `.scrollTo()` without animation. - ---- - -### 16. FilterRowView: Dual Animation on Hover - -**File**: `TablePro/Views/Filter/FilterRowView.swift:96-100` - -**Problem**: Both `withAnimation` inside `onHover` and `.animation()` modifier on the view. Double-animation during hover. - -**Fix**: Use only one animation mechanism. - ---- - -### 17. SidebarView: Animation on Tab Switch - -**File**: `TablePro/Views/Sidebar/SidebarView.swift:97` - -**Problem**: `.animation(.easeInOut(duration: 0.18), value: sidebarState.selectedSidebarTab)` applied to ZStack with opacity + frame modifiers. Both incoming and outgoing tabs animate simultaneously (3 view hierarchies). - ---- - -### 18. UpdaterBridge: Potential Retain Cycle in KVO - -**File**: `TablePro/Core/Services/Infrastructure/UpdaterBridge.swift:32-37` - -**Problem**: KVO closure captures `[weak self]` but creates inner `Task { @MainActor [weak self] }`. The double-weak pattern is correct but fragile if the inner task outlives the outer closure. - ---- - -### 19. DataGridView: Set Equality in Identity Struct - -**File**: `TablePro/Views/Results/DataGridView.swift:181-189` - -**Problem**: `DataGridIdentity` includes `hiddenColumns: Set` comparison, which is O(n) per `updateNSView` call. Tables with 100+ columns make this expensive. - ---- - -### 20. ThemeListView: Computed Filter Properties in Body - -**File**: `TablePro/Views/Settings/Appearance/ThemeListView.swift:14-27` - -**Problem**: `builtInThemes`, `registryThemes`, `customThemes` call `.filter()` on every body re-evaluation. - -**Fix**: Cache in `@State` and update only when `engine.availableThemes` changes. - ---- - -### 21. HistoryPanelView: onChange Without Debouncing - -**File**: `TablePro/Views/Editor/HistoryPanelView.swift:143-148` - -**Problem**: `onChange(of: dateFilter)` calls `loadData()` without debouncing. If dateFilter changes rapidly, data reloads pile up. - ---- - -### 22. TableStructureView: Cascading onChange Handlers - -**File**: `TablePro/Views/Structure/TableStructureView.swift:67-100` - -**Problem**: Five onChange handlers on columns, indexes, foreignKeys, selectedTab, selectedRows. Tab change triggers `onSelectedTabChanged()` which may modify columns/indexes, firing their own onChange handlers in a cascade. - ---- - -### 23. MainEditorContentView: onChange with Cache Invalidation - -**File**: `TablePro/Views/Main/Child/MainEditorContentView.swift:124-128` - -**Problem**: `onChange(of: tabManager.tabIds)` filters cache dictionaries. If `tabIds` is a computed property that recreates frequently, this fires unnecessarily. Filtering is O(n\*m). - ---- - -### 24. AppSettingsManager: Cascading didSet Updates - -**File**: `TablePro/Core/Storage/AppSettingsManager.swift:22-98` - -**Problem**: Each setting's `didSet` makes multiple cascading updates (storage save, theme engine update, sync tracker notification). Not batched — multiple settings changing in succession cause repeated I/O and notifications. - ---- - -## Verified Good Patterns - -These areas were audited and found to be correctly implemented: - -| Pattern | Files | Status | -| ---------------------------- | -------------------------------------------------------------------------- | ------------------------------------------ | -| O(1) string length checks | SQLContextAnalyzer, CompletionEngine, SQLEditorView | ✅ Uses `(string as NSString).length` | -| Static cached formatters | MainStatusBarView, DatePickerCellEditor, Date+Extensions, RightSidebarView | ✅ `static let` pattern | -| @ObservationIgnored | 86 occurrences across codebase | ✅ Properly excludes internal state | -| Actor-based concurrency | ConnectionHealthMonitor, TabDiskActor, SSHTunnelManager | ✅ Proper isolation | -| Identity-based reload guards | DataGridView | ✅ Prevents redundant NSTableView reloads | -| LazyVStack usage | AIChatPanelView, ColumnVisibilityPopover, FilterPanelView | ✅ Correct for scroll perf | -| DateFormattingService cache | DateFormattingService | ✅ NSCache with 10K entry limit | -| RowBuffer eviction | QueryTab | ✅ Keeps only 2 most-recent tabs in memory | -| Generation counter | queryGeneration in tabs | ✅ Prevents out-of-order result flashes | -| Tab persistence truncation | QueryTab.toPersistedTab, TabStateStorage | ✅ 500KB cap | -| Window lifecycle | WindowLifecycleMonitor | ✅ Proper weak refs, observer cleanup | -| Health monitor skip routine | ConnectionHealthMonitor | ✅ Skips healthy↔checking UI updates | - ---- - -## Fix Priority Guide - -**Immediate** (potential crashes): - -- [x] #1 MainActor.assumeIsolated in NSEvent monitors — Verified safe: Apple docs confirm local event monitors always run on main thread -- [x] #2 MainActor.assumeIsolated in SQLFormatterService — Fixed: Thread.isMainThread check with DispatchQueue.main.sync fallback -- [x] #4 Data race in SchemaProviderRegistry — Fixed: getOrCreate() cancels pending removal tasks - -**High** (CPU/memory waste): - -- [x] #3 Task leak in periodic services — Fixed: guard self != nil after Task.sleep in both AnalyticsService and LicenseManager -- [x] #5 Non-cached formatters in hot paths — Fixed: static let cached formatters in HistoryPanelView, ClickHousePartsView, String+HexDump -- [ ] #6 Sync I/O on MainActor — Deferred: requires larger refactor (move to background actor) -- [x] #8 O(n) string comparison on cursor move — Fixed: removed full string comparison, O(1) length check only -- [ ] #9 Cascading onChange handlers in MainContentView — Assessed: handlers are independent with debouncing where needed; no change required -- [x] #10 AIChatPanelView array enumeration — Fixed: ForEach uses messages directly, O(1) previous-message check - -**Medium** (optimization): - -- [ ] #7 connectionStatusVersion over-increment — Mitigated by per-connection version counters; global version used only by backward-compat alias -- [x] #11 QueryResultRow O(n) equality — Fixed: O(1) count check before element comparison -- [x] #12 ExportTableTreeView nested ForEach — Assessed: .contains(true) operates on 2-3 elements; export dialog is rare-use -- [x] #13 ThemePreviewCard array allocation — Assessed: Array() required for ForEach; arrays are 3-4 elements in rarely-rendered settings view -- [x] #14 ConnectionFormView onChange consolidation — Fixed: consolidated 5 onChange into single pgpassTrigger hash -- [x] #15 QuickSwitcherView animation stacking — Fixed: removed withAnimation wrapper, plain scrollTo -- [x] #16 FilterRowView dual animation — Fixed: removed redundant .animation() modifier - -**Low** (minor improvements): - -- [x] #17 SidebarView animation — Assessed: intentional UX, only fires on user-initiated tab switch -- [x] #18 UpdaterBridge KVO pattern — Verified: already uses [weak self] on both outer and inner closures -- [x] #19 DataGridView set equality — Assessed: Swift Set equality already checks count internally -- [x] #20 ThemeListView computed filters — Assessed: simple filters on small arrays in rarely-rendered settings view -- [x] #21 HistoryPanelView debouncing — Assessed: dateFilter is discrete picker, no debouncing needed -- [x] #22 TableStructureView cascade — Assessed: handlers already guarded with isReloadingAfterSave flag -- [x] #23 MainEditorContentView cache — Fixed: early-exit guard when caches are empty -- [ ] #24 AppSettingsManager cascading didSet — Low impact: only fires on user settings changes From a7605f83c78d32a7b52018d82245779659eb8171 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 21 Mar 2026 15:31:36 +0700 Subject: [PATCH 4/4] fix: address PR review findings from CodeRabbit --- .../Window/SuggestionController.swift | 3 +- TablePro/ContentView.swift | 4 ++ .../Formatting/SQLFormatterService.swift | 37 +++++++++---------- TablePro/Extensions/String+HexDump.swift | 7 +--- TablePro/Views/AIChat/AIChatPanelView.swift | 1 - TablePro/Views/Editor/HistoryPanelView.swift | 3 +- TablePro/Views/Main/MainContentView.swift | 4 +- 7 files changed, 30 insertions(+), 29 deletions(-) diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController.swift index a33c45dc8..54bef5957 100644 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController.swift +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController.swift @@ -147,7 +147,8 @@ public final class SuggestionController: NSWindowController { } if textView.view.window == nil { self.close() - } else if let firstResponder = window.firstResponder as? NSView, + } else if textView.view.window === window, + let firstResponder = window.firstResponder as? NSView, !firstResponder.isDescendant(of: textView.view) { self.close() } diff --git a/TablePro/ContentView.swift b/TablePro/ContentView.swift index e90bb4b30..9ee562e62 100644 --- a/TablePro/ContentView.swift +++ b/TablePro/ContentView.swift @@ -18,6 +18,7 @@ struct ContentView: View { let payload: EditorTabPayload? @State private var currentSession: ConnectionSession? + @State private var closingSessionId: UUID? @State private var columnVisibility: NavigationSplitViewVisibility = .all @State private var showNewConnectionSheet = false @State private var showEditConnectionSheet = false @@ -70,6 +71,7 @@ struct ContentView: View { // Right sidebar toggle is handled by MainContentView (has the binding) // Left sidebar toggle uses native NSSplitViewController.toggleSidebar via responder chain .onChange(of: DatabaseManager.shared.currentSessionId, initial: true) { _, newSessionId in + guard closingSessionId == nil else { return } let ourConnectionId = payload?.connectionId if ourConnectionId != nil { guard newSessionId == ourConnectionId else { return } @@ -331,6 +333,7 @@ struct ContentView: View { // MARK: - Connection Status private func handleConnectionStatusChange() { + guard closingSessionId == nil else { return } let sessions = DatabaseManager.shared.activeSessions let connectionId = payload?.connectionId ?? currentSession?.id ?? DatabaseManager.shared.currentSessionId guard let sid = connectionId else { @@ -339,6 +342,7 @@ struct ContentView: View { } guard let newSession = sessions[sid] else { if currentSession?.id == sid { + closingSessionId = sid rightPanelState?.teardown() rightPanelState = nil sessionState?.coordinator.teardown() diff --git a/TablePro/Core/Services/Formatting/SQLFormatterService.swift b/TablePro/Core/Services/Formatting/SQLFormatterService.swift index bd92824cf..d6d03f711 100644 --- a/TablePro/Core/Services/Formatting/SQLFormatterService.swift +++ b/TablePro/Core/Services/Formatting/SQLFormatterService.swift @@ -108,20 +108,13 @@ struct SQLFormatterService: SQLFormatterProtocol { /// Get or create the keyword uppercasing regex for a given database type private static func keywordRegex(for dialect: DatabaseType) -> NSRegularExpression? { keywordRegexLock.lock() - defer { keywordRegexLock.unlock() } - if let cached = keywordRegexCache[dialect] { + keywordRegexLock.unlock() return cached } + keywordRegexLock.unlock() - let provider: SQLDialectProvider - if Thread.isMainThread { - provider = MainActor.assumeIsolated { SQLDialectFactory.createDialect(for: dialect) } - } else { - provider = DispatchQueue.main.sync { - MainActor.assumeIsolated { SQLDialectFactory.createDialect(for: dialect) } - } - } + let provider = resolveDialectProvider(for: dialect) let allKeywords = provider.keywords.union(provider.functions).union(provider.dataTypes) let escapedKeywords = allKeywords.map { NSRegularExpression.escapedPattern(for: $0) } let pattern = "\\b(\(escapedKeywords.joined(separator: "|")))\\b" @@ -130,10 +123,24 @@ struct SQLFormatterService: SQLFormatterProtocol { return nil } + keywordRegexLock.lock() + defer { keywordRegexLock.unlock() } + if let cached = keywordRegexCache[dialect] { + return cached + } keywordRegexCache[dialect] = regex return regex } + private static func resolveDialectProvider(for dialect: DatabaseType) -> SQLDialectProvider { + if Thread.isMainThread { + return MainActor.assumeIsolated { SQLDialectFactory.createDialect(for: dialect) } + } + return DispatchQueue.main.sync { + MainActor.assumeIsolated { SQLDialectFactory.createDialect(for: dialect) } + } + } + // MARK: - Public API func format( @@ -159,15 +166,7 @@ struct SQLFormatterService: SQLFormatterProtocol { throw SQLFormatterError.invalidCursorPosition(cursor, max: sqlLength) } - // Get dialect provider - let dialectProvider: SQLDialectProvider - if Thread.isMainThread { - dialectProvider = MainActor.assumeIsolated { SQLDialectFactory.createDialect(for: dialect) } - } else { - dialectProvider = DispatchQueue.main.sync { - MainActor.assumeIsolated { SQLDialectFactory.createDialect(for: dialect) } - } - } + let dialectProvider = Self.resolveDialectProvider(for: dialect) // Format the SQL let formatted = formatSQL(sql, dialect: dialectProvider, databaseType: dialect, options: options) diff --git a/TablePro/Extensions/String+HexDump.swift b/TablePro/Extensions/String+HexDump.swift index 8c546845d..521119edc 100644 --- a/TablePro/Extensions/String+HexDump.swift +++ b/TablePro/Extensions/String+HexDump.swift @@ -8,11 +8,6 @@ import Foundation extension String { - private static let hexDumpNumberFormatter: NumberFormatter = { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - return formatter - }() /// Returns a classic hex dump representation of this string's bytes, or nil if empty. /// @@ -70,7 +65,7 @@ extension String { } if totalCount > maxBytes { - let formattedTotal = Self.hexDumpNumberFormatter.string(from: NSNumber(value: totalCount)) ?? "\(totalCount)" + let formattedTotal = totalCount.formatted(.number) lines.append("... (truncated, \(formattedTotal) bytes total)") } diff --git a/TablePro/Views/AIChat/AIChatPanelView.swift b/TablePro/Views/AIChat/AIChatPanelView.swift index 0ab72c125..2ff6bf66d 100644 --- a/TablePro/Views/AIChat/AIChatPanelView.swift +++ b/TablePro/Views/AIChat/AIChatPanelView.swift @@ -45,7 +45,6 @@ struct AIChatPanelView: View { } .onAppear { viewModel.connection = connection - viewModel.tables = tables } .task(id: tables) { viewModel.tables = tables diff --git a/TablePro/Views/Editor/HistoryPanelView.swift b/TablePro/Views/Editor/HistoryPanelView.swift index d2e006a4a..bb3cefd13 100644 --- a/TablePro/Views/Editor/HistoryPanelView.swift +++ b/TablePro/Views/Editor/HistoryPanelView.swift @@ -303,7 +303,8 @@ private extension HistoryPanelView { }() func buildSecondaryMetadata(_ entry: QueryHistoryEntry) -> String { - var text = "Executed: \(Self.metadataDateFormatter.string(from: entry.executedAt))" + let executedAt = Self.metadataDateFormatter.string(from: entry.executedAt) + var text = String(localized: "Executed: \(executedAt)") if !entry.wasSuccessful, let error = entry.errorMessage { text += "\nError: \(error)" diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 5fd54a09e..cc0cdbe10 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -785,7 +785,9 @@ struct MainContentView: View { let sessions = DatabaseManager.shared.activeSessions guard let session = sessions[connection.id] else { return } if session.isConnected && coordinator.needsLazyLoad { - guard !changeManager.hasChanges else { return } + let hasPendingEdits = changeManager.hasChanges + || (tabManager.selectedTab?.pendingChanges.hasChanges ?? false) + guard !hasPendingEdits else { return } coordinator.needsLazyLoad = false if let selectedTab = tabManager.selectedTab, !selectedTab.databaseName.isEmpty,