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..54bef5957 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,22 @@ 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 textView.view.window === window, + let firstResponder = window.firstResponder as? NSView, + !firstResponder.isDescendant(of: textView.view) { + self.close() + } } } @@ -174,10 +170,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..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 } @@ -102,59 +104,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 +330,63 @@ 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 { + if currentSession != nil { currentSession = nil } + return + } + guard let newSession = sessions[sid] else { + if currentSession?.id == sid { + closingSessionId = 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/Formatting/SQLFormatterService.swift b/TablePro/Core/Services/Formatting/SQLFormatterService.swift index 6e6050872..d6d03f711 100644 --- a/TablePro/Core/Services/Formatting/SQLFormatterService.swift +++ b/TablePro/Core/Services/Formatting/SQLFormatterService.swift @@ -108,13 +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 = 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" @@ -123,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( @@ -152,8 +166,7 @@ struct SQLFormatterService: SQLFormatterProtocol { throw SQLFormatterError.invalidCursorPosition(cursor, max: sqlLength) } - // Get dialect provider - let dialectProvider = 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/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/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/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/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/Extensions/String+HexDump.swift b/TablePro/Extensions/String+HexDump.swift index 568547232..521119edc 100644 --- a/TablePro/Extensions/String+HexDump.swift +++ b/TablePro/Extensions/String+HexDump.swift @@ -8,6 +8,7 @@ import Foundation extension String { + /// 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 +65,7 @@ extension String { } if totalCount > maxBytes { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - let formattedTotal = formatter.string(from: NSNumber(value: totalCount)) ?? "\(totalCount)" + let formattedTotal = totalCount.formatted(.number) 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..2ff6bf66d 100644 --- a/TablePro/Views/AIChat/AIChatPanelView.swift +++ b/TablePro/Views/AIChat/AIChatPanelView.swift @@ -45,12 +45,9 @@ struct AIChatPanelView: View { } .onAppear { 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 +167,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/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/HistoryPanelView.swift b/TablePro/Views/Editor/HistoryPanelView.swift index 54f8c1f98..bb3cefd13 100644 --- a/TablePro/Views/Editor/HistoryPanelView.swift +++ b/TablePro/Views/Editor/HistoryPanelView.swift @@ -295,12 +295,16 @@ 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 { + 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/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/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/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index ec7b7d509..cc0cdbe10 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,29 @@ 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 { + let hasPendingEdits = changeManager.hasChanges + || (tabManager.selectedTab?.pendingChanges.hasChanges ?? false) + guard !hasPendingEdits 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 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 {