From cb0d6031dd94b04c6d1eb8ee116acb731c899401 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 9 Mar 2026 14:40:25 +0700 Subject: [PATCH 1/9] perf: tie NSEvent monitors to editor focus lifecycle (P0-1, P0-2, P0-3) --- .../Core/AI/InlineSuggestionManager.swift | 43 +++++++++---- TablePro/Core/Vim/VimKeyInterceptor.swift | 50 ++++++++++----- TablePro/Core/Vim/VimTextBufferAdapter.swift | 63 +++++++++++++++++++ .../Views/Editor/SQLEditorCoordinator.swift | 39 ++++++++++++ 4 files changed, 165 insertions(+), 30 deletions(-) diff --git a/TablePro/Core/AI/InlineSuggestionManager.swift b/TablePro/Core/AI/InlineSuggestionManager.swift index 3242fb62..7d974616 100644 --- a/TablePro/Core/AI/InlineSuggestionManager.swift +++ b/TablePro/Core/AI/InlineSuggestionManager.swift @@ -24,6 +24,7 @@ final class InlineSuggestionManager { private var currentTask: Task? private let _keyEventMonitor = OSAllocatedUnfairLock(initialState: nil) private let _scrollObserver = OSAllocatedUnfairLock(initialState: nil) + private var isEditorFocused = false deinit { if let monitor = _keyEventMonitor.withLock({ $0 }) { NSEvent.removeMonitor(monitor) } @@ -57,14 +58,27 @@ final class InlineSuggestionManager { func install(controller: TextViewController, schemaProvider: SQLSchemaProvider?) { self.controller = controller self.schemaProvider = schemaProvider - installKeyEventMonitor() installScrollObserver() } + func editorDidFocus() { + guard !isEditorFocused else { return } + isEditorFocused = true + installKeyEventMonitor() + } + + func editorDidBlur() { + guard isEditorFocused else { return } + isEditorFocused = false + dismissSuggestion() + removeKeyEventMonitor() + } + /// Remove all observers and layers func uninstall() { guard !isUninstalled else { return } isUninstalled = true + isEditorFocused = false debounceTimer?.invalidate() debounceTimer = nil @@ -72,10 +86,7 @@ final class InlineSuggestionManager { currentTask = nil removeGhostLayer() - if let monitor = _keyEventMonitor.withLock({ $0 }) { - NSEvent.removeMonitor(monitor) - _keyEventMonitor.withLock { $0 = nil } - } + removeKeyEventMonitor() if let observer = _scrollObserver.withLock({ $0 }) { NotificationCenter.default.removeObserver(observer) @@ -362,13 +373,14 @@ final class InlineSuggestionManager { // MARK: - Key Event Monitor private func installKeyEventMonitor() { + removeKeyEventMonitor() _keyEventMonitor.withLock { $0 = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in - guard let self else { return event } + guard let self, self.isEditorFocused else { return event } + + guard AppSettingsManager.shared.ai.inlineSuggestEnabled else { return event } - // Only intercept when a suggestion is active guard self.currentSuggestion != nil else { return event } - // Only intercept when our text view is the first responder guard let textView = self.controller?.textView, event.window === textView.window, textView.window?.firstResponder === textView else { return event } @@ -378,25 +390,30 @@ final class InlineSuggestionManager { Task { @MainActor [weak self] in self?.acceptSuggestion() } - return nil // Consume the event + return nil case 53: // Escape — dismiss suggestion Task { @MainActor [weak self] in self?.dismissSuggestion() } - return nil // Consume the event + return nil default: - // Any other key — dismiss and pass through - // The text change handler will schedule a new suggestion Task { @MainActor [weak self] in self?.dismissSuggestion() } - return event // Pass through + return event } } } } + private func removeKeyEventMonitor() { + _keyEventMonitor.withLock { + if let monitor = $0 { NSEvent.removeMonitor(monitor) } + $0 = nil + } + } + // MARK: - Scroll Observer private func installScrollObserver() { diff --git a/TablePro/Core/Vim/VimKeyInterceptor.swift b/TablePro/Core/Vim/VimKeyInterceptor.swift index ef14113d..3b18c349 100644 --- a/TablePro/Core/Vim/VimKeyInterceptor.swift +++ b/TablePro/Core/Vim/VimKeyInterceptor.swift @@ -17,6 +17,7 @@ final class VimKeyInterceptor { private let _monitor = OSAllocatedUnfairLock(initialState: nil) private weak var controller: TextViewController? private let _popupCloseObserver = OSAllocatedUnfairLock(initialState: nil) + private var isEditorFocused = false deinit { if let monitor = _monitor.withLock({ $0 }) { NSEvent.removeMonitor(monitor) } @@ -28,22 +29,11 @@ final class VimKeyInterceptor { self.inlineSuggestionManager = inlineSuggestionManager } - /// Install the key event monitor + /// Install the interceptor on a controller (does not install the event monitor until editor is focused) func install(controller: TextViewController) { self.controller = controller uninstall() - _monitor.withLock { - $0 = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in - guard let self else { return event } - return self.handleKeyEvent(event) - } - } - - // Observe autocomplete popup close. When SuggestionController's popup - // consumes Escape (closes itself), we also need to exit Insert/Visual mode. - // queue: .main → handler runs synchronously when posted from main thread, - // so NSApp.currentEvent is still the Escape keyDown event. _popupCloseObserver.withLock { $0 = NotificationCenter.default.addObserver( forName: NSWindow.willCloseNotification, object: nil, @@ -67,18 +57,44 @@ final class VimKeyInterceptor { } } } - /// Remove the key event monitor + func editorDidFocus() { + guard !isEditorFocused else { return } + isEditorFocused = true + installMonitor() + } + + func editorDidBlur() { + guard isEditorFocused else { return } + isEditorFocused = false + removeMonitor() + } + + /// Remove all monitors and observers func uninstall() { - _monitor.withLock { - if let monitor = $0 { NSEvent.removeMonitor(monitor) } - $0 = nil - } + isEditorFocused = false + removeMonitor() _popupCloseObserver.withLock { if let observer = $0 { NotificationCenter.default.removeObserver(observer) } $0 = nil } } + private func installMonitor() { + _monitor.withLock { + $0 = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + guard let self, self.isEditorFocused else { return event } + return self.handleKeyEvent(event) + } + } + } + + private func removeMonitor() { + _monitor.withLock { + if let monitor = $0 { NSEvent.removeMonitor(monitor) } + $0 = nil + } + } + /// Arrow key Unicode scalars → Vim motion characters private static let arrowToVimKey: [UInt32: Character] = [ 0xF700: "k", // Up diff --git a/TablePro/Core/Vim/VimTextBufferAdapter.swift b/TablePro/Core/Vim/VimTextBufferAdapter.swift index 9bc83a62..7811c9ef 100644 --- a/TablePro/Core/Vim/VimTextBufferAdapter.swift +++ b/TablePro/Core/Vim/VimTextBufferAdapter.swift @@ -51,6 +51,65 @@ final class VimTextBufferAdapter: VimTextBuffer { cachedLineCount = nil } + /// Incrementally update the cached line count based on text change delta. + /// Avoids a full O(n) recount on every keystroke. + func textDidChange(in range: NSRange, replacementLength: Int) { + guard let textView else { + cachedLineCount = nil + return + } + + guard let cached = cachedLineCount else { return } + + let nsString = textView.string as NSString + + // Pure insertion: count newlines in the new text and apply delta + if range.length == 0 { + var addedNewlines = 0 + let end = range.location + replacementLength + if replacementLength > 0 && end <= nsString.length { + for i in range.location.. 0 && range.location + range.length <= oldNs.length { + let end = range.location + range.length + for i in range.location.. 0 && replacementEnd <= nsString.length { + for i in range.location.. NSRange { guard let textView else { return NSRange(location: 0, length: 0) } let nsString = textView.string as NSString @@ -196,6 +255,10 @@ final class VimTextBufferAdapter: VimTextBuffer { let maxLength = (textView.string as NSString).length - clampedLocation let clampedLength = max(0, min(range.length, maxLength)) let clampedRange = NSRange(location: clampedLocation, length: clampedLength) + + let currentRange = textView.selectedRange() + guard clampedRange != currentRange else { return } + textView.selectionManager.setSelectedRange(clampedRange) // CodeEditTextView's setSelectedRange (singular) doesn't call setNeedsDisplay, // so selection highlights (drawn in draw(_:)) won't render without this. diff --git a/TablePro/Views/Editor/SQLEditorCoordinator.swift b/TablePro/Views/Editor/SQLEditorCoordinator.swift index 9a39687b..13a05d9e 100644 --- a/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -31,6 +31,8 @@ final class SQLEditorCoordinator: TextViewCoordinator { /// triggering syntax highlight viewport recalculation on every keystroke. @ObservationIgnored private var frameChangeWorkItem: DispatchWorkItem? @ObservationIgnored private var clipboardMonitor: Any? + @ObservationIgnored private var firstResponderObserver: NSObjectProtocol? + @ObservationIgnored private var wasEditorFocused = false @ObservationIgnored private var didDestroy = false /// Test-only accessor for destroy state @@ -62,6 +64,9 @@ final class SQLEditorCoordinator: TextViewCoordinator { if let observer = editorSettingsObserver { NotificationCenter.default.removeObserver(observer) } + if let observer = firstResponderObserver { + NotificationCenter.default.removeObserver(observer) + } frameChangeWorkItem?.cancel() if let monitor = clipboardMonitor { NSEvent.removeMonitor(monitor) @@ -83,6 +88,7 @@ final class SQLEditorCoordinator: TextViewCoordinator { self?.installInlineSuggestionManager(controller: controller) self?.installVimModeIfEnabled(controller: controller) self?.installClipboardMonitor(controller: controller) + self?.installFirstResponderObserver() } } @@ -156,6 +162,11 @@ final class SQLEditorCoordinator: TextViewCoordinator { NSEvent.removeMonitor(monitor) clipboardMonitor = nil } + + if let observer = firstResponderObserver { + NotificationCenter.default.removeObserver(observer) + firstResponderObserver = nil + } } // MARK: - AI Context Menu @@ -257,6 +268,34 @@ final class SQLEditorCoordinator: TextViewCoordinator { } } + // MARK: - First Responder Tracking + + private func installFirstResponderObserver() { + firstResponderObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didUpdateNotification, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.checkFirstResponderChange() + } + } + } + + private func checkFirstResponderChange() { + let focused = isEditorFirstResponder + guard focused != wasEditorFocused else { return } + wasEditorFocused = focused + + if focused { + vimKeyInterceptor?.editorDidFocus() + inlineSuggestionManager?.editorDidFocus() + } else { + vimKeyInterceptor?.editorDidBlur() + inlineSuggestionManager?.editorDidBlur() + } + } + // MARK: - Clipboard Monitor private func installClipboardMonitor(controller: TextViewController) { From 8f6d94de2f1e80137edec7863686350e5df474ea Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 9 Mar 2026 14:40:30 +0700 Subject: [PATCH 2/9] perf: add regex windowing and 10K char highlight cap (P1-1, P1-2, P1-7, P3-1) --- .../Autocomplete/SQLContextAnalyzer.swift | 12 ++++- .../Views/AIChat/AIChatCodeBlockView.swift | 46 ++++++++++++------- .../Components/HighlightedSQLTextView.swift | 9 +++- .../Views/Editor/SQLCompletionAdapter.swift | 19 ++++---- .../Views/Results/JSONEditorContentView.swift | 20 +++++--- 5 files changed, 69 insertions(+), 37 deletions(-) diff --git a/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift b/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift index ce8473ac..ba975874 100644 --- a/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift +++ b/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift @@ -857,10 +857,18 @@ final class SQLContextAnalyzer { return .select // Column context } - let upper = textBeforeCursor.uppercased() + // Window to last ~5000 chars to avoid O(n) regex on large queries + let windowSize = 5000 + let nsText = textBeforeCursor as NSString + let windowedText: String + if nsText.length > windowSize { + windowedText = nsText.substring(from: nsText.length - windowSize) + } else { + windowedText = textBeforeCursor + } // Remove string literals and comments for analysis - let cleaned = removeStringsAndComments(from: upper) + let cleaned = removeStringsAndComments(from: windowedText) // Run regex-based clause detection FIRST — DDL contexts (CREATE TABLE, // ALTER TABLE, etc.) must take priority over function-arg detection, diff --git a/TablePro/Views/AIChat/AIChatCodeBlockView.swift b/TablePro/Views/AIChat/AIChatCodeBlockView.swift index 0a17798e..52f03040 100644 --- a/TablePro/Views/AIChat/AIChatCodeBlockView.swift +++ b/TablePro/Views/AIChat/AIChatCodeBlockView.swift @@ -178,37 +178,43 @@ struct AIChatCodeBlockView: View { } let nsCode = code as NSString - let fullRange = NSRange(location: 0, length: nsCode.length) + let maxHighlightLength = 10_000 + let highlightRange: NSRange + if nsCode.length > maxHighlightLength { + highlightRange = NSRange(location: 0, length: maxHighlightLength) + } else { + highlightRange = NSRange(location: 0, length: nsCode.length) + } // 1. Single-line comments: --.* - for match in SQLPatterns.singleLineComment.matches(in: code, range: fullRange) { + for match in SQLPatterns.singleLineComment.matches(in: code, range: highlightRange) { applyColor(match.range, color: .systemGreen, protect: true) } // 2. Multi-line comments: /* ... */ - for match in SQLPatterns.multiLineComment.matches(in: code, range: fullRange) { + for match in SQLPatterns.multiLineComment.matches(in: code, range: highlightRange) { applyColor(match.range, color: .systemGreen, protect: true) } // 3. String literals: '...' - for match in SQLPatterns.stringLiteral.matches(in: code, range: fullRange) { + for match in SQLPatterns.stringLiteral.matches(in: code, range: highlightRange) { applyColor(match.range, color: .systemRed, protect: true) } // 4. Numbers: \b\d+(\.\d+)?\b - for match in SQLPatterns.number.matches(in: code, range: fullRange) { + for match in SQLPatterns.number.matches(in: code, range: highlightRange) { guard !isProtected(match.range) else { continue } applyColor(match.range, color: .systemPurple) } // 5. NULL / TRUE / FALSE - for match in SQLPatterns.nullBoolLiteral.matches(in: code, range: fullRange) { + for match in SQLPatterns.nullBoolLiteral.matches(in: code, range: highlightRange) { guard !isProtected(match.range) else { continue } applyColor(match.range, color: .systemOrange) } // 6. SQL keywords - for match in SQLPatterns.keyword.matches(in: code, range: fullRange) { + for match in SQLPatterns.keyword.matches(in: code, range: highlightRange) { guard !isProtected(match.range) else { continue } applyColor(match.range, color: .systemBlue) } @@ -281,45 +287,51 @@ struct AIChatCodeBlockView: View { } let nsCode = code as NSString - let fullRange = NSRange(location: 0, length: nsCode.length) + let maxHighlightLength = 10_000 + let highlightRange: NSRange + if nsCode.length > maxHighlightLength { + highlightRange = NSRange(location: 0, length: maxHighlightLength) + } else { + highlightRange = NSRange(location: 0, length: nsCode.length) + } - for match in JSPatterns.singleLineComment.matches(in: code, range: fullRange) { + for match in JSPatterns.singleLineComment.matches(in: code, range: highlightRange) { applyColor(match.range, color: .systemGreen, protect: true) } - for match in JSPatterns.multiLineComment.matches(in: code, range: fullRange) { + for match in JSPatterns.multiLineComment.matches(in: code, range: highlightRange) { applyColor(match.range, color: .systemGreen, protect: true) } - for match in JSPatterns.doubleQuoteString.matches(in: code, range: fullRange) { + for match in JSPatterns.doubleQuoteString.matches(in: code, range: highlightRange) { applyColor(match.range, color: .systemRed, protect: true) } - for match in JSPatterns.singleQuoteString.matches(in: code, range: fullRange) { + for match in JSPatterns.singleQuoteString.matches(in: code, range: highlightRange) { applyColor(match.range, color: .systemRed, protect: true) } - for match in JSPatterns.number.matches(in: code, range: fullRange) { + for match in JSPatterns.number.matches(in: code, range: highlightRange) { guard !isProtected(match.range) else { continue } applyColor(match.range, color: .systemPurple) } - for match in JSPatterns.boolNull.matches(in: code, range: fullRange) { + for match in JSPatterns.boolNull.matches(in: code, range: highlightRange) { guard !isProtected(match.range) else { continue } applyColor(match.range, color: .systemOrange) } - for match in JSPatterns.keyword.matches(in: code, range: fullRange) { + for match in JSPatterns.keyword.matches(in: code, range: highlightRange) { guard !isProtected(match.range) else { continue } applyColor(match.range, color: .systemPink) } - for match in JSPatterns.method.matches(in: code, range: fullRange) { + for match in JSPatterns.method.matches(in: code, range: highlightRange) { guard !isProtected(match.range) else { continue } applyColor(match.range, color: .systemBlue) } - for match in JSPatterns.property.matches(in: code, range: fullRange) { + for match in JSPatterns.property.matches(in: code, range: highlightRange) { guard !isProtected(match.range) else { continue } applyColor(match.range, color: .systemTeal) } diff --git a/TablePro/Views/Components/HighlightedSQLTextView.swift b/TablePro/Views/Components/HighlightedSQLTextView.swift index 28d4bb4d..268c2132 100644 --- a/TablePro/Views/Components/HighlightedSQLTextView.swift +++ b/TablePro/Views/Components/HighlightedSQLTextView.swift @@ -181,8 +181,15 @@ struct HighlightedSQLTextView: NSViewRepresentable { activePatterns = Self.syntaxPatterns } let text = textStorage.string + let maxHighlightLength = 10_000 + let highlightRange: NSRange + if textStorage.length > maxHighlightLength { + highlightRange = NSRange(location: 0, length: maxHighlightLength) + } else { + highlightRange = fullRange + } for (regex, color) in activePatterns { - let matches = regex.matches(in: text, options: [], range: fullRange) + let matches = regex.matches(in: text, options: [], range: highlightRange) for match in matches { textStorage.addAttribute(.foregroundColor, value: color, range: match.range) } diff --git a/TablePro/Views/Editor/SQLCompletionAdapter.swift b/TablePro/Views/Editor/SQLCompletionAdapter.swift index eb237adb..eb92abf6 100644 --- a/TablePro/Views/Editor/SQLCompletionAdapter.swift +++ b/TablePro/Views/Editor/SQLCompletionAdapter.swift @@ -160,19 +160,18 @@ final class SQLCompletionAdapter: CodeSuggestionDelegate { // MARK: - Fuzzy Matching - /// Fuzzy matching: checks if all pattern characters appear in target in order private static func fuzzyMatch(pattern: String, target: String) -> Bool { - var patternIndex = pattern.startIndex - var targetIndex = target.startIndex - - while patternIndex < pattern.endIndex && targetIndex < target.endIndex { - if pattern[patternIndex] == target[targetIndex] { - patternIndex = pattern.index(after: patternIndex) + let nsPattern = pattern as NSString + let nsTarget = target as NSString + var patternIndex = 0 + var targetIndex = 0 + while patternIndex < nsPattern.length && targetIndex < nsTarget.length { + if nsPattern.character(at: patternIndex) == nsTarget.character(at: targetIndex) { + patternIndex += 1 } - targetIndex = target.index(after: targetIndex) + targetIndex += 1 } - - return patternIndex == pattern.endIndex + return patternIndex == nsPattern.length } } diff --git a/TablePro/Views/Results/JSONEditorContentView.swift b/TablePro/Views/Results/JSONEditorContentView.swift index ed3622d1..f067adab 100644 --- a/TablePro/Views/Results/JSONEditorContentView.swift +++ b/TablePro/Views/Results/JSONEditorContentView.swift @@ -149,6 +149,13 @@ private struct JSONSyntaxTextView: NSViewRepresentable { let fullRange = NSRange(location: 0, length: length) let font = textView.font ?? NSFont.monospacedSystemFont(ofSize: DesignConstants.FontSize.medium, weight: .regular) let content = textStorage.string + let maxHighlightLength = 10_000 + let highlightRange: NSRange + if length > maxHighlightLength { + highlightRange = NSRange(location: 0, length: maxHighlightLength) + } else { + highlightRange = fullRange + } textStorage.beginEditing() @@ -156,18 +163,17 @@ private struct JSONSyntaxTextView: NSViewRepresentable { textStorage.addAttribute(.font, value: font, range: fullRange) textStorage.addAttribute(.foregroundColor, value: NSColor.labelColor, range: fullRange) - applyPattern(JSONHighlightPatterns.string, color: .systemRed, in: textStorage, content: content) + applyPattern(JSONHighlightPatterns.string, color: .systemRed, in: textStorage, content: content, range: highlightRange) - let keyRange = NSRange(location: 0, length: length) - for match in JSONHighlightPatterns.key.matches(in: content, range: keyRange) { + for match in JSONHighlightPatterns.key.matches(in: content, range: highlightRange) { let captureRange = match.range(at: 1) if captureRange.location != NSNotFound { textStorage.addAttribute(.foregroundColor, value: NSColor.systemBlue, range: captureRange) } } - applyPattern(JSONHighlightPatterns.number, color: .systemPurple, in: textStorage, content: content) - applyPattern(JSONHighlightPatterns.booleanNull, color: .systemOrange, in: textStorage, content: content) + applyPattern(JSONHighlightPatterns.number, color: .systemPurple, in: textStorage, content: content, range: highlightRange) + applyPattern(JSONHighlightPatterns.booleanNull, color: .systemOrange, in: textStorage, content: content, range: highlightRange) textStorage.endEditing() } @@ -176,9 +182,9 @@ private struct JSONSyntaxTextView: NSViewRepresentable { _ regex: NSRegularExpression, color: NSColor, in textStorage: NSTextStorage, - content: String + content: String, + range: NSRange ) { - let range = NSRange(location: 0, length: textStorage.length) for match in regex.matches(in: content, range: range) { textStorage.addAttribute(.foregroundColor, value: color, range: match.range) } From 24e0fb1fa2ad24d47c37b049e7d5b5f8c28453b7 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 9 Mar 2026 14:40:32 +0700 Subject: [PATCH 3/9] perf: optimize DataGrid rendering with version tracking and VoiceOver caching (P1-4, P1-5, P2-4, P2-7) --- .../Views/Results/DataGridCellFactory.swift | 42 +++++++++++++------ TablePro/Views/Results/DataGridView.swift | 24 ++++++----- 2 files changed, 42 insertions(+), 24 deletions(-) diff --git a/TablePro/Views/Results/DataGridCellFactory.swift b/TablePro/Views/Results/DataGridCellFactory.swift index 447e35d2..aa542e8a 100644 --- a/TablePro/Views/Results/DataGridCellFactory.swift +++ b/TablePro/Views/Results/DataGridCellFactory.swift @@ -34,7 +34,24 @@ final class DataGridCellFactory { private var nullDisplayString: String = AppSettingsManager.shared.dataGrid.nullDisplay private var settingsObserver: NSObjectProtocol? + // MARK: - Cached VoiceOver State + + private static var cachedVoiceOverEnabled: Bool = NSWorkspace.shared.isVoiceOverEnabled + private static let voiceOverObserver: NSObjectProtocol? = { + NotificationCenter.default.addObserver( + forName: NSWorkspace.accessibilityDisplayOptionsDidChangeNotification, + object: nil, + queue: .main + ) { _ in + Task { @MainActor in + DataGridCellFactory.cachedVoiceOverEnabled = NSWorkspace.shared.isVoiceOverEnabled + } + } + }() + init() { + _ = Self.voiceOverObserver + settingsObserver = NotificationCenter.default.addObserver( forName: .dataGridSettingsDidChange, object: nil, @@ -123,7 +140,7 @@ final class DataGridCellFactory { cell.stringValue = "\(row + 1)" cell.textColor = visualState.isDeleted ? CellColors.deletedText : .secondaryLabelColor - if NSWorkspace.shared.isVoiceOverEnabled { + if Self.cachedVoiceOverEnabled { cellView.setAccessibilityLabel(String(localized: "Row \(row + 1)")) } @@ -295,7 +312,7 @@ final class DataGridCellFactory { CATransaction.commit() // Accessibility: describe cell content for VoiceOver - if !isLargeDataset && NSWorkspace.shared.isVoiceOverEnabled { + if !isLargeDataset && Self.cachedVoiceOverEnabled { let displayValue = value ?? String(localized: "NULL") cell.setAccessibilityLabel( String(localized: "Row \(row + 1), column \(columnIndex + 1): \(displayValue)") @@ -410,28 +427,27 @@ final class DataGridCellFactory { columnIndex: Int, rowProvider: InMemoryRowProvider ) -> CGFloat { - let headerAttributes: [NSAttributedString.Key: Any] = [.font: Self.headerFont] - - // Start with header width (proportional font — needs CoreText, but only once) - let headerSize = (columnName as NSString).size(withAttributes: headerAttributes) - var maxWidth = headerSize.width + 48 // padding for sort indicator + margins + // For header: use character count * average proportional char width + // instead of CoreText measurement. ~0.6 of mono width is a good estimate + // for proportional system font. + let headerCharCount = (columnName as NSString).length + var maxWidth = CGFloat(headerCharCount) * Self.monoCharWidth * 0.75 + 48 - // Sample cell content to find max width let totalRows = rowProvider.totalRowCount - let step = max(1, totalRows / Self.sampleRowCount) + let columnCount = rowProvider.columns.count + // Reduce sample count for wide tables to keep total work bounded + let effectiveSampleCount = columnCount > 50 ? 10 : Self.sampleRowCount + let step = max(1, totalRows / effectiveSampleCount) let charWidth = Self.monoCharWidth for i in stride(from: 0, to: totalRows, by: step) { guard let row = rowProvider.row(at: i), let value = row.value(at: columnIndex) else { continue } - // Use O(1) NSString length, capped for width estimation. - // Monospaced font: width = charCount * glyphAdvance (no CoreText needed). let charCount = min((value as NSString).length, Self.maxMeasureChars) - let cellWidth = CGFloat(charCount) * charWidth + 16 // 16 = cell padding + let cellWidth = CGFloat(charCount) * charWidth + 16 maxWidth = max(maxWidth, cellWidth) - // Early exit if already at max if maxWidth >= Self.maxColumnWidth { return Self.maxColumnWidth } diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index c4e2fc3c..f51d5cc6 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -217,17 +217,18 @@ struct DataGridView: NSViewRepresentable { coordinator.rowProvider = rowProvider - // Re-apply pending cell edits to the new rowProvider instance. - // SwiftUI may supply a cached rowProvider that doesn't reflect - // in-flight edits tracked by the changeManager. - for change in changeManager.changes { - guard let rowChange = change as? RowChange else { continue } - for cellChange in rowChange.cellChanges { - coordinator.rowProvider.updateValue( - cellChange.newValue, - at: rowChange.rowIndex, - columnIndex: cellChange.columnIndex - ) + // Re-apply pending cell edits only when changes have been modified + if changeManager.reloadVersion != coordinator.lastReapplyVersion { + coordinator.lastReapplyVersion = changeManager.reloadVersion + for change in changeManager.changes { + guard let rowChange = change as? RowChange else { continue } + for cellChange in rowChange.cellChanges { + coordinator.rowProvider.updateValue( + cellChange.newValue, + at: rowChange.rowIndex, + columnIndex: cellChange.columnIndex + ) + } } } @@ -636,6 +637,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData fileprivate var lastIdentity: DataGridIdentity? var lastReloadVersion: Int = 0 + var lastReapplyVersion: Int = -1 private(set) var cachedRowCount: Int = 0 private(set) var cachedColumnCount: Int = 0 var isSyncingSortDescriptors: Bool = false From a8a6a46b5ec4a25f778342a85f2d652c2f072ba1 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 9 Mar 2026 14:40:27 +0700 Subject: [PATCH 4/9] perf: split sessionVersion into fine-grained counters and extract AppState properties (P0-4, P0-5) --- CHANGELOG.md | 2 ++ TablePro/ContentView.swift | 2 +- TablePro/Core/Database/DatabaseManager.swift | 18 ++++++++++++++---- TablePro/Views/Editor/QueryEditorView.swift | 10 ++++++---- .../Main/Child/MainEditorContentView.swift | 6 ++++-- 5 files changed, 27 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f95703b..790ba8b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Split DatabaseManager.sessionVersion into fine-grained connectionListVersion and connectionStatusVersion to reduce cascade re-renders +- Extract AppState property reads into local lets in view bodies for explicit granular observation tracking - Reorganized project directory structure: Services, Utilities, Models split into domain-specific subdirectories - Database driver code moved from monolithic app binary into independent plugin bundles under `Plugins/` diff --git a/TablePro/ContentView.swift b/TablePro/ContentView.swift index 317da12b..09966355 100644 --- a/TablePro/ContentView.swift +++ b/TablePro/ContentView.swift @@ -126,7 +126,7 @@ struct ContentView: View { columnVisibility = .detailOnly } } - .onChange(of: DatabaseManager.shared.sessionVersion, initial: true) { _, _ in + .onChange(of: DatabaseManager.shared.connectionStatusVersion, initial: true) { _, _ in let sessions = DatabaseManager.shared.activeSessions let connectionId = payload?.connectionId ?? currentSession?.id ?? DatabaseManager.shared.currentSessionId guard let sid = connectionId else { diff --git a/TablePro/Core/Database/DatabaseManager.swift b/TablePro/Core/Database/DatabaseManager.swift index 1e8c0a17..61ec395c 100644 --- a/TablePro/Core/Database/DatabaseManager.swift +++ b/TablePro/Core/Database/DatabaseManager.swift @@ -17,12 +17,22 @@ final class DatabaseManager { /// All active connection sessions private(set) var activeSessions: [UUID: ConnectionSession] = [:] { - didSet { sessionVersion &+= 1 } + didSet { + if Set(oldValue.keys) != Set(activeSessions.keys) { + connectionListVersion &+= 1 + } + connectionStatusVersion &+= 1 + } } - /// Monotonically increasing counter; incremented on every mutation of activeSessions. - /// Used by views for `.onChange` since `[UUID: ConnectionSession]` is not `Equatable`. - private(set) var sessionVersion: Int = 0 + /// Incremented only when sessions are added or removed (keys change). + private(set) var connectionListVersion: Int = 0 + + /// Incremented when any session state changes (status, driver, metadata, etc.). + private(set) var connectionStatusVersion: Int = 0 + + /// Backward-compatible alias for views not yet migrated to fine-grained counters. + var sessionVersion: Int { connectionStatusVersion } /// Currently selected session ID (displayed in UI) private(set) var currentSessionId: UUID? diff --git a/TablePro/Views/Editor/QueryEditorView.swift b/TablePro/Views/Editor/QueryEditorView.swift index dfaa5130..e34003f5 100644 --- a/TablePro/Views/Editor/QueryEditorView.swift +++ b/TablePro/Views/Editor/QueryEditorView.swift @@ -27,9 +27,11 @@ struct QueryEditorView: View { @State private var isVimEnabled = AppSettingsManager.shared.editor.vimModeEnabled var body: some View { + let hasQuery = appState.hasQueryText + VStack(alignment: .leading, spacing: 0) { // Editor header with toolbar (above editor, higher z-index) - editorToolbar + editorToolbar(hasQueryText: hasQuery) .zIndex(1) Divider() @@ -58,7 +60,7 @@ struct QueryEditorView: View { // MARK: - Toolbar - private var editorToolbar: some View { + private func editorToolbar(hasQueryText: Bool) -> some View { HStack { Text("Query") .font(.headline) @@ -107,7 +109,7 @@ struct QueryEditorView: View { } .menuStyle(.borderlessButton) .fixedSize() - .disabled(!appState.hasQueryText) + .disabled(!hasQueryText) } else { Button { NotificationCenter.default.post(name: .explainQuery, object: nil) @@ -119,7 +121,7 @@ struct QueryEditorView: View { } .buttonStyle(.bordered) .controlSize(.small) - .disabled(!appState.hasQueryText) + .disabled(!hasQueryText) } // Execute button diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index a33ecc1a..74c32106 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -85,6 +85,8 @@ struct MainEditorContentView: View { // MARK: - Body var body: some View { + let isHistoryVisible = appState.isHistoryPanelVisible + VStack(spacing: 0) { // Native macOS window tabs replace the custom tab bar. // Each window-tab contains a single tab — no ZStack keep-alive needed. @@ -95,7 +97,7 @@ struct MainEditorContentView: View { } // Global History Panel - if appState.isHistoryPanelVisible { + if isHistoryVisible { Divider() HistoryPanelView() .frame(height: 300) @@ -103,7 +105,7 @@ struct MainEditorContentView: View { } } .background(.background) - .animation(.easeInOut(duration: 0.2), value: appState.isHistoryPanelVisible) + .animation(.easeInOut(duration: 0.2), value: isHistoryVisible) .onChange(of: tabManager.tabs.count) { // Clean up caches for closed tabs let openTabIds = Set(tabManager.tabs.map(\.id)) From 469eb95cf89f6da4f12f13676fa23b054fef6486 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 9 Mar 2026 14:40:34 +0700 Subject: [PATCH 5/9] perf: coalesce onChange handlers and scope @Bindable to usage site (P1-3, P1-6, P2-9) --- .../Main/Child/MainEditorContentView.swift | 28 +++++++++---------- TablePro/Views/Main/MainContentView.swift | 13 +++++++-- 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 74c32106..c6c27635 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -23,7 +23,7 @@ struct MainEditorContentView: View { // MARK: - Dependencies var tabManager: QueryTabManager - @Bindable var coordinator: MainContentCoordinator + var coordinator: MainContentCoordinator var changeManager: DataChangeManager var filterStateManager: FilterStateManager let connection: DatabaseConnection @@ -123,8 +123,17 @@ struct MainEditorContentView: View { tabProviderVersions = tabProviderVersions.filter { openTabIds.contains($0.key) } tabProviderMetaVersions = tabProviderMetaVersions.filter { openTabIds.contains($0.key) } } - .onChange(of: tabManager.selectedTabId) { + .onChange(of: tabManager.selectedTabId) { _, newId in updateHasQueryText() + + guard let newId, let tab = tabManager.selectedTab else { return } + if tabProviderVersions[newId] != tab.resultVersion + || tabProviderMetaVersions[newId] != tab.metadataVersion { + let provider = makeRowProvider(for: tab) + tabRowProviders[newId] = provider + tabProviderVersions[newId] = tab.resultVersion + tabProviderMetaVersions[newId] = tab.metadataVersion + } } .onAppear { updateHasQueryText() @@ -152,18 +161,6 @@ struct MainEditorContentView: View { tabProviderVersions[tab.id] = tab.resultVersion tabProviderMetaVersions[tab.id] = tab.metadataVersion } - .onChange(of: tabManager.selectedTabId) { _, newId in - guard let newId, let tab = tabManager.selectedTab else { return } - - // Cache provider for new tab if not already cached - if tabProviderVersions[newId] != tab.resultVersion - || tabProviderMetaVersions[newId] != tab.metadataVersion { - let provider = makeRowProvider(for: tab) - tabRowProviders[newId] = provider - tabProviderVersions[newId] = tab.resultVersion - tabProviderMetaVersions[newId] = tab.metadataVersion - } - } } // MARK: - Tab Content @@ -182,12 +179,13 @@ struct MainEditorContentView: View { @ViewBuilder private func queryTabContent(tab: QueryTab) -> some View { + @Bindable var bindableCoordinator = coordinator VSplitView { // Query Editor (top) VStack(spacing: 0) { QueryEditorView( queryText: queryTextBinding(for: tab), - cursorPositions: $coordinator.cursorPositions, + cursorPositions: $bindableCoordinator.cursorPositions, onExecute: { coordinator.runQuery() }, schemaProvider: coordinator.schemaProvider, databaseType: coordinator.connection.type, diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index cb117843..a1147dca 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -44,6 +44,7 @@ struct MainContentView: View { @State private var commandActions: MainContentCommandActions? @State private var queryResultsSummaryCache: (tabId: UUID, version: Int, summary: String?)? @State private var inspectorUpdateTask: Task? + @State private var pendingTabSwitch: Task? /// Stable identifier for this window in WindowLifecycleMonitor @State private var windowId = UUID() @State private var hasInitialized = false @@ -259,8 +260,14 @@ struct MainContentView: View { .modifier(ToolbarTintModifier(connectionColor: connection.color)) .task { await initializeAndRestoreTabs() } .onChange(of: tabManager.selectedTabId) { _, newTabId in - handleTabSelectionChange(from: previousSelectedTabId, to: newTabId) - previousSelectedTabId = newTabId + pendingTabSwitch?.cancel() + pendingTabSwitch = Task { @MainActor in + // Let other onChange handlers (tabs, resultColumns) settle first + try? await Task.sleep(for: .milliseconds(16)) + guard !Task.isCancelled else { return } + handleTabSelectionChange(from: previousSelectedTabId, to: newTabId) + previousSelectedTabId = newTabId + } } .onChange(of: tabManager.tabs) { _, newTabs in handleTabsChange(newTabs) @@ -326,6 +333,7 @@ struct MainContentView: View { } } .onChange(of: selectedRowIndices) { _, newIndices in + // Synchronous: cheap state updates that don't cascade AppState.shared.hasRowSelection = !newIndices.isEmpty if !newIndices.isEmpty, AppSettingsManager.shared.dataGrid.autoShowInspector, @@ -333,6 +341,7 @@ struct MainContentView: View { { rightPanelState.isPresented = true } + // Deferred: expensive inspector rebuild coalesced with other triggers scheduleInspectorUpdate() } } From 91324a17cb927b93ef6392a616d28e1b45557638 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 9 Mar 2026 14:40:36 +0700 Subject: [PATCH 6/9] perf: misc cleanup - tunnel handlers, onReceive coalescing, monitor consolidation (P2-1, P2-2, P2-6, P2-10, P2-11, P2-12) --- TablePro/Core/SSH/SSHTunnelManager.swift | 22 +++++++--- .../Infrastructure/UpdaterBridge.swift | 5 +++ .../Services/Query/RowOperationsManager.swift | 24 ++++++---- .../Views/Editor/SQLEditorCoordinator.swift | 44 +++++++++---------- .../Views/Main/MainContentCoordinator.swift | 3 +- .../Views/Structure/TableStructureView.swift | 38 +++++++++++----- 6 files changed, 86 insertions(+), 50 deletions(-) diff --git a/TablePro/Core/SSH/SSHTunnelManager.swift b/TablePro/Core/SSH/SSHTunnelManager.swift index 14a565dc..95881fab 100644 --- a/TablePro/Core/SSH/SSHTunnelManager.swift +++ b/TablePro/Core/SSH/SSHTunnelManager.swift @@ -71,26 +71,28 @@ actor SSHTunnelManager { private func startHealthCheck() { healthCheckTask = Task { [weak self] in while !Task.isCancelled { - try? await Task.sleep(for: .seconds(30)) + try? await Task.sleep(for: .seconds(300)) guard !Task.isCancelled else { break } await self?.checkTunnelHealth() } } } - /// Check if tunnels are still alive and attempt reconnection if needed private func checkTunnelHealth() async { for (connectionId, tunnel) in tunnels { - // Check if process is still running if !tunnel.process.isRunning { - Self.logger.warning("SSH tunnel for \(connectionId) died, attempting reconnection...") - - // Notify DatabaseManager to reconnect - await notifyTunnelDied(connectionId: connectionId) + Self.logger.warning("SSH tunnel for \(connectionId) died (detected by fallback health check)") + await handleTunnelDeath(connectionId: connectionId) } } } + private func handleTunnelDeath(connectionId: UUID) async { + guard tunnels.removeValue(forKey: connectionId) != nil else { return } + Self.processRegistry.withLock { $0.removeValue(forKey: connectionId) } + await notifyTunnelDied(connectionId: connectionId) + } + /// Notify that a tunnel has died (DatabaseManager should handle reconnection) private func notifyTunnelDied(connectionId: UUID) async { await MainActor.run { @@ -204,6 +206,12 @@ actor SSHTunnelManager { tunnels[connectionId] = tunnel Self.processRegistry.withLock { $0[connectionId] = launch.process } + launch.process.terminationHandler = { [weak self] _ in + Task { [weak self] in + await self?.handleTunnelDeath(connectionId: connectionId) + } + } + return localPort } diff --git a/TablePro/Core/Services/Infrastructure/UpdaterBridge.swift b/TablePro/Core/Services/Infrastructure/UpdaterBridge.swift index 4f44d6d3..833bcdd8 100644 --- a/TablePro/Core/Services/Infrastructure/UpdaterBridge.swift +++ b/TablePro/Core/Services/Infrastructure/UpdaterBridge.swift @@ -16,6 +16,11 @@ final class UpdaterBridge { @ObservationIgnored private var observation: NSKeyValueObservation? + deinit { + observation?.invalidate() + observation = nil + } + init() { controller = SPUStandardUpdaterController( startingUpdater: true, diff --git a/TablePro/Core/Services/Query/RowOperationsManager.swift b/TablePro/Core/Services/Query/RowOperationsManager.swift index 22b910c5..c7c089fb 100644 --- a/TablePro/Core/Services/Query/RowOperationsManager.swift +++ b/TablePro/Core/Services/Query/RowOperationsManager.swift @@ -314,26 +314,34 @@ final class RowOperationsManager { } let indicesToCopy = isTruncated ? Array(sortedIndices.prefix(Self.maxClipboardRows)) : sortedIndices - var lines: [String] = [] - // Add header row if requested + let columnCount = resultRows.first?.values.count ?? 1 + let estimatedRowLength = columnCount * 12 + var result = "" + result.reserveCapacity(indicesToCopy.count * estimatedRowLength) + if includeHeaders, !columns.isEmpty { - lines.append(columns.joined(separator: "\t")) + for (colIdx, col) in columns.enumerated() { + if colIdx > 0 { result.append("\t") } + result.append(col) + } } for rowIndex in indicesToCopy { guard rowIndex < resultRows.count else { continue } let row = resultRows[rowIndex] - let line = row.values.map { $0 ?? "NULL" }.joined(separator: "\t") - lines.append(line) + if !result.isEmpty { result.append("\n") } + for (colIdx, value) in row.values.enumerated() { + if colIdx > 0 { result.append("\t") } + result.append(value ?? "NULL") + } } if isTruncated { - lines.append("(truncated, showing first \(Self.maxClipboardRows) of \(totalSelected) rows)") + result.append("\n(truncated, showing first \(Self.maxClipboardRows) of \(totalSelected) rows)") } - let text = lines.joined(separator: "\n") - ClipboardService.shared.writeText(text) + ClipboardService.shared.writeText(result) } // MARK: - Paste Rows diff --git a/TablePro/Views/Editor/SQLEditorCoordinator.swift b/TablePro/Views/Editor/SQLEditorCoordinator.swift index 13a05d9e..03880cc0 100644 --- a/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -73,6 +73,27 @@ final class SQLEditorCoordinator: TextViewCoordinator { } } + private func cleanupMonitors() { + if let monitor = rightClickMonitor { + NSEvent.removeMonitor(monitor) + rightClickMonitor = nil + } + if let observer = editorSettingsObserver { + NotificationCenter.default.removeObserver(observer) + editorSettingsObserver = nil + } + if let observer = firstResponderObserver { + NotificationCenter.default.removeObserver(observer) + firstResponderObserver = nil + } + frameChangeWorkItem?.cancel() + frameChangeWorkItem = nil + if let monitor = clipboardMonitor { + NSEvent.removeMonitor(monitor) + clipboardMonitor = nil + } + } + // MARK: - TextViewCoordinator func prepareCoordinator(controller: TextViewController) { @@ -140,33 +161,12 @@ final class SQLEditorCoordinator: TextViewCoordinator { func destroy() { didDestroy = true - frameChangeWorkItem?.cancel() - frameChangeWorkItem = nil - uninstallVimKeyInterceptor() inlineSuggestionManager?.uninstall() inlineSuggestionManager = nil - if let obs = editorSettingsObserver { - NotificationCenter.default.removeObserver(obs) - editorSettingsObserver = nil - } - - if let monitor = rightClickMonitor { - NSEvent.removeMonitor(monitor) - rightClickMonitor = nil - } - - if let monitor = clipboardMonitor { - NSEvent.removeMonitor(monitor) - clipboardMonitor = nil - } - - if let observer = firstResponderObserver { - NotificationCenter.default.removeObserver(observer) - firstResponderObserver = nil - } + cleanupMonitors() } // MARK: - AI Context Menu diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 9d66f436..c1e528d6 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -1352,11 +1352,11 @@ private extension MainContentCoordinator { connectionType: DatabaseType, schemaResult: SchemaResult? ) { - // Phase 2a: Fire-and-forget exact COUNT(*) to refine approximate count. let quotedTable = connectionType.quoteIdentifier(tableName) Task { [weak self] in guard let self else { return } try? await Task.sleep(nanoseconds: 200_000_000) + guard !self.isTearingDown else { return } guard let mainDriver = DatabaseManager.shared.driver(for: connectionId) else { return } let countResult = try? await mainDriver.execute( query: "SELECT COUNT(*) FROM \(quotedTable)" @@ -1383,6 +1383,7 @@ private extension MainContentCoordinator { Task { [weak self] in guard let self else { return } try? await Task.sleep(nanoseconds: 200_000_000) + guard !self.isTearingDown else { return } // Use schema if available, otherwise fetch column info for enum parsing let columnInfo: [ColumnInfo] diff --git a/TablePro/Views/Structure/TableStructureView.swift b/TablePro/Views/Structure/TableStructureView.swift index 63b899e1..bc3a3e57 100644 --- a/TablePro/Views/Structure/TableStructureView.swift +++ b/TablePro/Views/Structure/TableStructureView.swift @@ -7,6 +7,7 @@ // import AppKit +import Combine import os import SwiftUI import UniformTypeIdentifiers @@ -80,27 +81,40 @@ struct TableStructureView: View { AppState.shared.hasStructureChanges = newValue } .onReceive(NotificationCenter.default.publisher(for: .refreshData), perform: onRefreshData) - .onReceive(NotificationCenter.default.publisher(for: .saveStructureChanges)) { _ in - if structureChangeManager.hasChanges && selectedTab != .ddl { - Task { - await executeSchemaChanges() + .onReceive( + Publishers.Merge( + NotificationCenter.default.publisher(for: .saveStructureChanges), + NotificationCenter.default.publisher(for: .previewStructureSQL) + ) + .debounce(for: .milliseconds(50), scheduler: RunLoop.main) + ) { notification in + if notification.name == .saveStructureChanges { + if structureChangeManager.hasChanges && selectedTab != .ddl { + Task { + await executeSchemaChanges() + } } + } else { + generateStructurePreviewSQL() } } - .onReceive(NotificationCenter.default.publisher(for: .previewStructureSQL)) { _ in - generateStructurePreviewSQL() - } .onReceive(NotificationCenter.default.publisher(for: .copySelectedRows)) { _ in handleCopyRows(selectedRows) } .onReceive(NotificationCenter.default.publisher(for: .pasteRows)) { _ in handlePaste() } - .onReceive(NotificationCenter.default.publisher(for: .undoChange)) { _ in - handleUndo() - } - .onReceive(NotificationCenter.default.publisher(for: .redoChange)) { _ in - handleRedo() + .onReceive( + Publishers.Merge( + NotificationCenter.default.publisher(for: .undoChange), + NotificationCenter.default.publisher(for: .redoChange) + ) + ) { notification in + if notification.name == .undoChange { + handleUndo() + } else { + handleRedo() + } } } From ff00d301f852ef5cbf827155c2a36791a2be320d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 9 Mar 2026 15:30:15 +0700 Subject: [PATCH 7/9] test: add Vim/InlineSuggestion focus lifecycle and VimTextBufferAdapter perf tests --- .../Core/AI/InlineSuggestionManager.swift | 4 + TablePro/Core/Vim/VimKeyInterceptor.swift | 4 + .../InlineSuggestionManagerFocusTests.swift | 63 ++++++ .../Vim/VimKeyInterceptorFocusTests.swift | 75 +++++++ .../Vim/VimTextBufferAdapterPerfTests.swift | 191 ++++++++++++++++++ 5 files changed, 337 insertions(+) create mode 100644 TableProTests/Core/AI/InlineSuggestionManagerFocusTests.swift create mode 100644 TableProTests/Core/Vim/VimKeyInterceptorFocusTests.swift create mode 100644 TableProTests/Core/Vim/VimTextBufferAdapterPerfTests.swift diff --git a/TablePro/Core/AI/InlineSuggestionManager.swift b/TablePro/Core/AI/InlineSuggestionManager.swift index 7d974616..d6649af3 100644 --- a/TablePro/Core/AI/InlineSuggestionManager.swift +++ b/TablePro/Core/AI/InlineSuggestionManager.swift @@ -24,7 +24,11 @@ final class InlineSuggestionManager { private var currentTask: Task? private let _keyEventMonitor = OSAllocatedUnfairLock(initialState: nil) private let _scrollObserver = OSAllocatedUnfairLock(initialState: nil) +<<<<<<< HEAD private var isEditorFocused = false +======= + private(set) var isEditorFocused = false +>>>>>>> 6939cb8 (test: add Vim/InlineSuggestion focus lifecycle and VimTextBufferAdapter perf tests) deinit { if let monitor = _keyEventMonitor.withLock({ $0 }) { NSEvent.removeMonitor(monitor) } diff --git a/TablePro/Core/Vim/VimKeyInterceptor.swift b/TablePro/Core/Vim/VimKeyInterceptor.swift index 3b18c349..88ff57ef 100644 --- a/TablePro/Core/Vim/VimKeyInterceptor.swift +++ b/TablePro/Core/Vim/VimKeyInterceptor.swift @@ -17,7 +17,11 @@ final class VimKeyInterceptor { private let _monitor = OSAllocatedUnfairLock(initialState: nil) private weak var controller: TextViewController? private let _popupCloseObserver = OSAllocatedUnfairLock(initialState: nil) +<<<<<<< HEAD private var isEditorFocused = false +======= + private(set) var isEditorFocused = false +>>>>>>> 6939cb8 (test: add Vim/InlineSuggestion focus lifecycle and VimTextBufferAdapter perf tests) deinit { if let monitor = _monitor.withLock({ $0 }) { NSEvent.removeMonitor(monitor) } diff --git a/TableProTests/Core/AI/InlineSuggestionManagerFocusTests.swift b/TableProTests/Core/AI/InlineSuggestionManagerFocusTests.swift new file mode 100644 index 00000000..33ca98d2 --- /dev/null +++ b/TableProTests/Core/AI/InlineSuggestionManagerFocusTests.swift @@ -0,0 +1,63 @@ +// +// InlineSuggestionManagerFocusTests.swift +// TableProTests +// +// Regression tests for InlineSuggestionManager focus lifecycle +// + +@testable import TablePro +import Testing + +@Suite("InlineSuggestionManager Focus Lifecycle") +@MainActor +struct InlineSuggestionManagerFocusTests { + @Test("Initial state: isEditorFocused is false") + func initialStateIsFalse() { + let manager = InlineSuggestionManager() + #expect(manager.isEditorFocused == false) + } + + @Test("After editorDidFocus: isEditorFocused is true") + func focusSetsTrue() { + let manager = InlineSuggestionManager() + manager.editorDidFocus() + #expect(manager.isEditorFocused == true) + } + + @Test("After editorDidBlur: isEditorFocused is false") + func blurSetsFalse() { + let manager = InlineSuggestionManager() + manager.editorDidFocus() + manager.editorDidBlur() + #expect(manager.isEditorFocused == false) + } + + @Test("Focus/blur cycle works correctly") + func focusBlurCycle() { + let manager = InlineSuggestionManager() + manager.editorDidFocus() + #expect(manager.isEditorFocused == true) + manager.editorDidBlur() + #expect(manager.isEditorFocused == false) + manager.editorDidFocus() + #expect(manager.isEditorFocused == true) + } + + @Test("Multiple focus calls are idempotent") + func multipleFocusCalls() { + let manager = InlineSuggestionManager() + manager.editorDidFocus() + manager.editorDidFocus() + manager.editorDidFocus() + #expect(manager.isEditorFocused == true) + } + + @Test("Multiple blur calls are idempotent") + func multipleBlurCalls() { + let manager = InlineSuggestionManager() + manager.editorDidBlur() + manager.editorDidBlur() + manager.editorDidBlur() + #expect(manager.isEditorFocused == false) + } +} diff --git a/TableProTests/Core/Vim/VimKeyInterceptorFocusTests.swift b/TableProTests/Core/Vim/VimKeyInterceptorFocusTests.swift new file mode 100644 index 00000000..4646542f --- /dev/null +++ b/TableProTests/Core/Vim/VimKeyInterceptorFocusTests.swift @@ -0,0 +1,75 @@ +// +// VimKeyInterceptorFocusTests.swift +// TableProTests +// +// Regression tests for VimKeyInterceptor focus lifecycle +// + +@testable import TablePro +import Testing + +@Suite("VimKeyInterceptor Focus Lifecycle") +@MainActor +struct VimKeyInterceptorFocusTests { + private func makeInterceptor() -> VimKeyInterceptor { + let buffer = VimTextBufferMock(text: "hello") + let engine = VimEngine(buffer: buffer) + return VimKeyInterceptor(engine: engine, inlineSuggestionManager: nil) + } + + @Test("Initial state: isEditorFocused is false") + func initialStateIsFalse() { + let interceptor = makeInterceptor() + #expect(interceptor.isEditorFocused == false) + } + + @Test("After editorDidFocus: isEditorFocused is true") + func focusSetsTrue() { + let interceptor = makeInterceptor() + interceptor.editorDidFocus() + #expect(interceptor.isEditorFocused == true) + } + + @Test("After editorDidBlur: isEditorFocused is false") + func blurSetsFalse() { + let interceptor = makeInterceptor() + interceptor.editorDidFocus() + interceptor.editorDidBlur() + #expect(interceptor.isEditorFocused == false) + } + + @Test("After uninstall: isEditorFocused is false") + func uninstallResetsFocused() { + let interceptor = makeInterceptor() + interceptor.editorDidFocus() + interceptor.uninstall() + #expect(interceptor.isEditorFocused == false) + } + + @Test("Focus/blur/focus cycle works correctly") + func focusBlurFocusCycle() { + let interceptor = makeInterceptor() + interceptor.editorDidFocus() + #expect(interceptor.isEditorFocused == true) + interceptor.editorDidBlur() + #expect(interceptor.isEditorFocused == false) + interceptor.editorDidFocus() + #expect(interceptor.isEditorFocused == true) + } + + @Test("editorDidBlur when already blurred is a no-op") + func blurWhenAlreadyBlurred() { + let interceptor = makeInterceptor() + interceptor.editorDidBlur() + interceptor.editorDidBlur() + #expect(interceptor.isEditorFocused == false) + } + + @Test("editorDidFocus when already focused is a no-op") + func focusWhenAlreadyFocused() { + let interceptor = makeInterceptor() + interceptor.editorDidFocus() + interceptor.editorDidFocus() + #expect(interceptor.isEditorFocused == true) + } +} diff --git a/TableProTests/Core/Vim/VimTextBufferAdapterPerfTests.swift b/TableProTests/Core/Vim/VimTextBufferAdapterPerfTests.swift new file mode 100644 index 00000000..a4f6b042 --- /dev/null +++ b/TableProTests/Core/Vim/VimTextBufferAdapterPerfTests.swift @@ -0,0 +1,191 @@ +// +// VimTextBufferAdapterPerfTests.swift +// TableProTests +// +// Regression tests for VimTextBufferAdapter incremental lineCount +// and setSelectedRange guard +// + +import AppKit +import CodeEditTextView +@testable import TablePro +import Testing + +@Suite("VimTextBufferAdapter Incremental LineCount") +@MainActor +struct VimTextBufferAdapterPerfTests { + private final class StubDelegate: TextViewDelegate {} + + private func makeTextView(string: String) -> TextView { + let textView = TextView( + string: string, + font: .monospacedSystemFont(ofSize: 12, weight: .regular), + textColor: .labelColor, + lineHeightMultiplier: 1.0, + wrapLines: false, + isEditable: true, + isSelectable: true, + letterSpacing: 1.0, + delegate: StubDelegate() + ) + textView.frame = NSRect(x: 0, y: 0, width: 500, height: 500) + textView.layout() + return textView + } + + private func makeAdapter(string: String) -> (VimTextBufferAdapter, TextView) { + let textView = makeTextView(string: string) + let adapter = VimTextBufferAdapter(textView: textView) + return (adapter, textView) + } + + // MARK: - lineCount + + @Test("lineCount returns 1 for single line") + func singleLineCount() { + let (adapter, _) = makeAdapter(string: "hello world") + #expect(adapter.lineCount == 1) + } + + @Test("lineCount returns correct count for multi-line text") + func multiLineCount() { + let (adapter, _) = makeAdapter(string: "a\nb\nc") + #expect(adapter.lineCount == 3) + } + + @Test("lineCount returns 1 for empty text") + func emptyLineCount() { + let (adapter, _) = makeAdapter(string: "") + #expect(adapter.lineCount == 1) + } + + @Test("lineCount for text ending with newline") + func trailingNewlineCount() { + let (adapter, _) = makeAdapter(string: "a\nb\n") + #expect(adapter.lineCount == 3) + } + + // MARK: - textDidChange incremental (pure insertion) + + @Test("textDidChange with pure insertion updates line count incrementally") + func insertionUpdatesLineCount() { + let (adapter, textView) = makeAdapter(string: "hello") + + // Prime the cache + let initial = adapter.lineCount + #expect(initial == 1) + + // Simulate inserting "\nworld" at offset 5 + textView.replaceCharacters(in: NSRange(location: 5, length: 0), with: "\nworld") + adapter.textDidChange(in: NSRange(location: 5, length: 0), replacementLength: 6) + + #expect(adapter.lineCount == 2) + } + + @Test("textDidChange with multi-newline insertion") + func multiNewlineInsertion() { + let (adapter, textView) = makeAdapter(string: "hello") + + _ = adapter.lineCount // prime cache + + textView.replaceCharacters(in: NSRange(location: 5, length: 0), with: "\n\n\n") + adapter.textDidChange(in: NSRange(location: 5, length: 0), replacementLength: 3) + + #expect(adapter.lineCount == 4) + } + + // MARK: - textDidChange fallback on deletion + + @Test("textDidChange with deletion invalidates cache") + func deletionInvalidatesCache() { + let (adapter, textView) = makeAdapter(string: "a\nb\nc") + + let initial = adapter.lineCount + #expect(initial == 3) + + // Simulate deleting "\nb" (range.length > 0 means it's not pure insertion) + textView.replaceCharacters(in: NSRange(location: 1, length: 2), with: "") + adapter.textDidChange(in: NSRange(location: 1, length: 2), replacementLength: 0) + + // Cache should be invalidated; next access does a full recount + #expect(adapter.lineCount == 2) + } + + // MARK: - textDidChange(oldText:...) incremental replacement + + @Test("textDidChange with oldText correctly computes delta for replacement") + func oldTextReplacementDelta() { + let originalText = "a\nb\nc" + let (adapter, textView) = makeAdapter(string: originalText) + + let initial = adapter.lineCount + #expect(initial == 3) + + // Replace "b\nc" (range 2..4, contains 1 newline) with "x" (0 newlines) + textView.replaceCharacters(in: NSRange(location: 2, length: 3), with: "x") + adapter.textDidChange(oldText: originalText, in: NSRange(location: 2, length: 3), replacementLength: 1) + + // 3 original - 1 removed newline + 0 added = 2 + #expect(adapter.lineCount == 2) + } + + @Test("textDidChange with oldText adding newlines") + func oldTextAddingNewlines() { + let originalText = "abc" + let (adapter, textView) = makeAdapter(string: originalText) + + _ = adapter.lineCount // prime: 1 + + // Replace "b" with "x\ny\nz" (adding 2 newlines) + textView.replaceCharacters(in: NSRange(location: 1, length: 1), with: "x\ny\nz") + adapter.textDidChange(oldText: originalText, in: NSRange(location: 1, length: 1), replacementLength: 5) + + // 1 original - 0 removed + 2 added = 3 + #expect(adapter.lineCount == 3) + } + + // MARK: - setSelectedRange guard + + @Test("setSelectedRange with same range skips update") + func setSelectedRangeSameRangeIsNoOp() { + let (adapter, textView) = makeAdapter(string: "hello world") + let range = NSRange(location: 3, length: 0) + + // Set initial selection + adapter.setSelectedRange(range) + let firstRange = textView.selectedRange() + + // Set the same range again — should be a no-op due to the guard + adapter.setSelectedRange(range) + let secondRange = textView.selectedRange() + + #expect(firstRange == secondRange) + } + + @Test("setSelectedRange with different range does update") + func setSelectedRangeDifferentRangeUpdates() { + let (adapter, textView) = makeAdapter(string: "hello world") + + adapter.setSelectedRange(NSRange(location: 0, length: 0)) + let initialRange = textView.selectedRange() + + adapter.setSelectedRange(NSRange(location: 5, length: 0)) + let updatedRange = textView.selectedRange() + + #expect(initialRange != updatedRange) + #expect(updatedRange.location == 5) + } + + @Test("setSelectedRange with selection length sets needsDisplay") + func setSelectedRangeWithLengthSetsDisplay() { + let (adapter, _) = makeAdapter(string: "hello world") + + // Set a range with length > 0 — the method sets needsDisplay = true + adapter.setSelectedRange(NSRange(location: 0, length: 5)) + + // Just verify no crash and selection is correct + let range = adapter.selectedRange() + #expect(range.location == 0) + #expect(range.length == 5) + } +} From db10bfc9c7ae9fcead168a696462079672d13998 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 9 Mar 2026 15:40:45 +0700 Subject: [PATCH 8/9] test: add 100+ regression tests for all performance fixes --- .../Core/AI/InlineSuggestionManager.swift | 4 - TablePro/Core/Vim/VimKeyInterceptor.swift | 4 - .../Views/Editor/SQLCompletionAdapter.swift | 2 +- ...LContextAnalyzerCaseInsensitiveTests.swift | 256 +++++++++++ .../SQLContextAnalyzerWindowingTests.swift | 140 ++++++ .../DatabaseManagerVersionTests.swift | 134 ++++++ .../SSH/SSHTunnelManagerHealthTests.swift | 120 +++++ .../RowOperationsManagerCopyTests.swift | 215 +++++++++ .../Vim/VimTextBufferAdapterPerfTests.swift | 2 +- .../Views/Components/HighlightCapTests.swift | 435 ++++++++++++++++++ .../SQLCompletionAdapterFuzzyTests.swift | 132 ++++++ .../SQLEditorCoordinatorCleanupTests.swift | 85 ++++ .../DataGridCellFactoryPerfTests.swift | 190 ++++++++ 13 files changed, 1709 insertions(+), 10 deletions(-) create mode 100644 TableProTests/Core/Autocomplete/SQLContextAnalyzerCaseInsensitiveTests.swift create mode 100644 TableProTests/Core/Autocomplete/SQLContextAnalyzerWindowingTests.swift create mode 100644 TableProTests/Core/Database/DatabaseManagerVersionTests.swift create mode 100644 TableProTests/Core/SSH/SSHTunnelManagerHealthTests.swift create mode 100644 TableProTests/Core/Services/RowOperationsManagerCopyTests.swift create mode 100644 TableProTests/Views/Components/HighlightCapTests.swift create mode 100644 TableProTests/Views/Editor/SQLCompletionAdapterFuzzyTests.swift create mode 100644 TableProTests/Views/Editor/SQLEditorCoordinatorCleanupTests.swift create mode 100644 TableProTests/Views/Results/DataGridCellFactoryPerfTests.swift diff --git a/TablePro/Core/AI/InlineSuggestionManager.swift b/TablePro/Core/AI/InlineSuggestionManager.swift index d6649af3..c58de9b0 100644 --- a/TablePro/Core/AI/InlineSuggestionManager.swift +++ b/TablePro/Core/AI/InlineSuggestionManager.swift @@ -24,11 +24,7 @@ final class InlineSuggestionManager { private var currentTask: Task? private let _keyEventMonitor = OSAllocatedUnfairLock(initialState: nil) private let _scrollObserver = OSAllocatedUnfairLock(initialState: nil) -<<<<<<< HEAD - private var isEditorFocused = false -======= private(set) var isEditorFocused = false ->>>>>>> 6939cb8 (test: add Vim/InlineSuggestion focus lifecycle and VimTextBufferAdapter perf tests) deinit { if let monitor = _keyEventMonitor.withLock({ $0 }) { NSEvent.removeMonitor(monitor) } diff --git a/TablePro/Core/Vim/VimKeyInterceptor.swift b/TablePro/Core/Vim/VimKeyInterceptor.swift index 88ff57ef..8f3b5055 100644 --- a/TablePro/Core/Vim/VimKeyInterceptor.swift +++ b/TablePro/Core/Vim/VimKeyInterceptor.swift @@ -17,11 +17,7 @@ final class VimKeyInterceptor { private let _monitor = OSAllocatedUnfairLock(initialState: nil) private weak var controller: TextViewController? private let _popupCloseObserver = OSAllocatedUnfairLock(initialState: nil) -<<<<<<< HEAD - private var isEditorFocused = false -======= private(set) var isEditorFocused = false ->>>>>>> 6939cb8 (test: add Vim/InlineSuggestion focus lifecycle and VimTextBufferAdapter perf tests) deinit { if let monitor = _monitor.withLock({ $0 }) { NSEvent.removeMonitor(monitor) } diff --git a/TablePro/Views/Editor/SQLCompletionAdapter.swift b/TablePro/Views/Editor/SQLCompletionAdapter.swift index eb92abf6..9d56c274 100644 --- a/TablePro/Views/Editor/SQLCompletionAdapter.swift +++ b/TablePro/Views/Editor/SQLCompletionAdapter.swift @@ -160,7 +160,7 @@ final class SQLCompletionAdapter: CodeSuggestionDelegate { // MARK: - Fuzzy Matching - private static func fuzzyMatch(pattern: String, target: String) -> Bool { + nonisolated static func fuzzyMatch(pattern: String, target: String) -> Bool { let nsPattern = pattern as NSString let nsTarget = target as NSString var patternIndex = 0 diff --git a/TableProTests/Core/Autocomplete/SQLContextAnalyzerCaseInsensitiveTests.swift b/TableProTests/Core/Autocomplete/SQLContextAnalyzerCaseInsensitiveTests.swift new file mode 100644 index 00000000..9a6a38f9 --- /dev/null +++ b/TableProTests/Core/Autocomplete/SQLContextAnalyzerCaseInsensitiveTests.swift @@ -0,0 +1,256 @@ +// +// SQLContextAnalyzerCaseInsensitiveTests.swift +// TableProTests +// +// Regression tests verifying clause detection works case-insensitively +// after removal of uppercased() normalization. +// + +@testable import TablePro +import Testing + +@Suite("SQLContextAnalyzer Case-Insensitive Clause Detection") +struct SQLContextAnalyzerCaseInsensitiveTests { + private let analyzer = SQLContextAnalyzer() + + // MARK: - SELECT + + @Test("Uppercase SELECT detected") + func uppercaseSelect() { + let query = "SELECT " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .select) + } + + @Test("Lowercase select detected") + func lowercaseSelect() { + let query = "select " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .select) + } + + @Test("Mixed case Select detected") + func mixedCaseSelect() { + let query = "Select " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .select) + } + + // MARK: - WHERE + + @Test("Uppercase WHERE detected") + func uppercaseWhere() { + let query = "SELECT * FROM users WHERE " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .where_) + } + + @Test("Lowercase where detected") + func lowercaseWhere() { + let query = "select * from users where " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .where_) + } + + @Test("Mixed case Where detected") + func mixedCaseWhere() { + let query = "Select * From users Where " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .where_) + } + + // MARK: - FROM + + @Test("Uppercase FROM detected") + func uppercaseFrom() { + let query = "SELECT * FROM " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .from) + } + + @Test("Lowercase from detected") + func lowercaseFrom() { + let query = "select * from " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .from) + } + + @Test("Mixed case From detected") + func mixedCaseFrom() { + let query = "Select * From " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .from) + } + + // MARK: - INSERT INTO + + @Test("Uppercase INSERT INTO detected") + func uppercaseInsertInto() { + let query = "INSERT INTO " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .into) + } + + @Test("Lowercase insert into detected") + func lowercaseInsertInto() { + let query = "insert into " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .into) + } + + @Test("Mixed case Insert Into detected") + func mixedCaseInsertInto() { + let query = "Insert Into " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .into) + } + + // MARK: - UPDATE SET + + @Test("Uppercase UPDATE SET detected") + func uppercaseUpdateSet() { + let query = "UPDATE users SET " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .set) + } + + @Test("Lowercase update set detected") + func lowercaseUpdateSet() { + let query = "update users set " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .set) + } + + @Test("Mixed case Update Set detected") + func mixedCaseUpdateSet() { + let query = "Update users Set " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .set) + } + + // MARK: - DELETE FROM + + @Test("Uppercase DELETE FROM detected") + func uppercaseDeleteFrom() { + let query = "DELETE FROM " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .from) + } + + @Test("Lowercase delete from detected") + func lowercaseDeleteFrom() { + let query = "delete from " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .from) + } + + // MARK: - ORDER BY + + @Test("Uppercase ORDER BY detected") + func uppercaseOrderBy() { + let query = "SELECT * FROM users ORDER BY " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .orderBy) + } + + @Test("Lowercase order by detected") + func lowercaseOrderBy() { + let query = "select * from users order by " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .orderBy) + } + + @Test("Mixed case Order By detected") + func mixedCaseOrderBy() { + let query = "Select * From users Order By " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .orderBy) + } + + // MARK: - GROUP BY + + @Test("Uppercase GROUP BY detected") + func uppercaseGroupBy() { + let query = "SELECT * FROM users GROUP BY " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .groupBy) + } + + @Test("Lowercase group by detected") + func lowercaseGroupBy() { + let query = "select * from users group by " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .groupBy) + } + + // MARK: - HAVING + + @Test("Uppercase HAVING detected") + func uppercaseHaving() { + let query = "SELECT COUNT(*) FROM users GROUP BY status HAVING " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .having) + } + + @Test("Lowercase having detected") + func lowercaseHaving() { + let query = "select count(*) from users group by status having " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .having) + } + + // MARK: - JOIN + + @Test("Uppercase JOIN detected") + func uppercaseJoin() { + let query = "SELECT * FROM users JOIN " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .join) + } + + @Test("Lowercase join detected") + func lowercaseJoin() { + let query = "select * from users join " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .join) + } + + @Test("Lowercase left join detected") + func lowercaseLeftJoin() { + let query = "select * from users left join " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .join) + } + + // MARK: - ALTER TABLE + + @Test("Lowercase alter table detected") + func lowercaseAlterTable() { + let query = "alter table users " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .alterTable) + } + + @Test("Mixed case Alter Table detected") + func mixedCaseAlterTable() { + let query = "Alter Table users " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .alterTable) + } + + // MARK: - Mixed Case Full Queries + + @Test("Fully mixed case query detects correct clause") + func fullyMixedCaseQuery() { + let query = "sElEcT * fRoM users wHeRe " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .where_) + } + + @Test("Random casing on complex query") + func randomCasingComplexQuery() { + let query = "SeLeCt id, name FrOm users WhErE active = 1 OrDeR bY " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .orderBy) + } +} diff --git a/TableProTests/Core/Autocomplete/SQLContextAnalyzerWindowingTests.swift b/TableProTests/Core/Autocomplete/SQLContextAnalyzerWindowingTests.swift new file mode 100644 index 00000000..c3afd963 --- /dev/null +++ b/TableProTests/Core/Autocomplete/SQLContextAnalyzerWindowingTests.swift @@ -0,0 +1,140 @@ +// +// SQLContextAnalyzerWindowingTests.swift +// TableProTests +// +// Regression tests for SQLContextAnalyzer clause detection on large queries. +// Ensures windowing optimizations preserve correct clause detection. +// + +@testable import TablePro +import Testing + +@Suite("SQLContextAnalyzer Windowing") +struct SQLContextAnalyzerWindowingTests { + private let analyzer = SQLContextAnalyzer() + + // MARK: - Normal Short Queries + + @Test("Short SELECT WHERE query detects WHERE clause") + func shortSelectWhereDetectsWhereClause() { + let query = "SELECT * FROM users WHERE " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .where_) + } + + @Test("Short SELECT query detects SELECT clause") + func shortSelectDetectsSelectClause() { + let query = "SELECT " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .select) + } + + @Test("Short FROM query detects FROM clause") + func shortFromDetectsFromClause() { + let query = "SELECT id FROM " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .from) + } + + // MARK: - Large Query with Clause at End + + @Test("Large query with WHERE at end detects WHERE clause") + func largeQueryWhereAtEndDetectsCorrectly() { + let padding = String(repeating: "a", count: 6_000) + let query = "SELECT \(padding) FROM users WHERE " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .where_) + } + + @Test("Large query with ORDER BY at end detects ORDER BY clause") + func largeQueryOrderByAtEnd() { + let padding = String(repeating: "x", count: 6_000) + let query = "SELECT \(padding) FROM users ORDER BY " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .orderBy) + } + + @Test("Large query with GROUP BY at end detects GROUP BY clause") + func largeQueryGroupByAtEnd() { + let padding = String(repeating: "x", count: 6_000) + let query = "SELECT \(padding) FROM users GROUP BY " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .groupBy) + } + + @Test("Large query with JOIN at end detects JOIN clause") + func largeQueryJoinAtEnd() { + let padding = String(repeating: "x", count: 6_000) + let query = "SELECT \(padding) FROM users JOIN " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .join) + } + + // MARK: - Large Query with INSERT Context + + @Test("Large INSERT with VALUES keyword near cursor detects values context") + func largeQueryInsertIntoValuesAtEnd() { + let padding = String(repeating: "x", count: 4000) + let query = "INSERT INTO users (\(padding)) VALUES ('a', 'b'), " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .values) + } + + // MARK: - Clause Keyword Only at Beginning (Far from Cursor) + + @Test("Large query with SELECT and many columns, cursor at end") + func largeQuerySelectManyColumns() { + let columns = (1...600).map { "col\($0)" }.joined(separator: ", ") + let query = "SELECT \(columns), " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .select) + } + + // MARK: - Edge Cases + + @Test("Empty text returns unknown clause type") + func emptyTextReturnsUnknown() { + let context = analyzer.analyze(query: "", cursorPosition: 0) + #expect(context.clauseType == .unknown) + } + + @Test("Whitespace-only text returns unknown clause type") + func whitespaceOnlyReturnsUnknown() { + let query = " \t\n " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .unknown) + } + + @Test("Cursor at position zero returns unknown") + func cursorAtZeroReturnsUnknown() { + let query = "SELECT * FROM users" + let context = analyzer.analyze(query: query, cursorPosition: 0) + #expect(context.clauseType == .unknown) + } + + @Test("Cursor in middle of large query detects correct clause") + func cursorInMiddleOfLargeQuery() { + let padding = String(repeating: "x", count: 3_000) + let query = "SELECT * FROM users WHERE \(padding) AND " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .and) + } + + // MARK: - Multiple Clauses in Large Query + + @Test("Large query with multiple clauses detects last clause near cursor") + func multipleClausesDetectsLastOne() { + let padding = String(repeating: "column_name, ", count: 400) + let query = "SELECT \(padding)id FROM users WHERE status = 1 ORDER BY " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .orderBy) + } + + @Test("HAVING clause after large GROUP BY expression") + func havingAfterLargeGroupBy() { + let columns = (1...500).map { "col\($0)" }.joined(separator: ", ") + let query = "SELECT \(columns) FROM data GROUP BY \(columns) HAVING " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .having) + } +} diff --git a/TableProTests/Core/Database/DatabaseManagerVersionTests.swift b/TableProTests/Core/Database/DatabaseManagerVersionTests.swift new file mode 100644 index 00000000..5b0bab8d --- /dev/null +++ b/TableProTests/Core/Database/DatabaseManagerVersionTests.swift @@ -0,0 +1,134 @@ +// +// DatabaseManagerVersionTests.swift +// TableProTests +// +// Tests for fine-grained version counters on DatabaseManager. +// + +import Foundation +import Testing +@testable import TablePro + +@Suite("DatabaseManager Version Counters") +@MainActor +struct DatabaseManagerVersionTests { + private func makeSession(id: UUID = UUID()) -> (UUID, ConnectionSession) { + let connection = DatabaseConnection(id: id, name: "Test") + let session = ConnectionSession(connection: connection) + return (id, session) + } + + @Test("Adding a session increments both connectionListVersion and connectionStatusVersion") + func addSessionIncrementsBothCounters() { + let manager = DatabaseManager.shared + let listBefore = manager.connectionListVersion + let statusBefore = manager.connectionStatusVersion + + let (id, session) = makeSession() + manager.injectSession(session, for: id) + + #expect(manager.connectionListVersion == listBefore + 1) + #expect(manager.connectionStatusVersion == statusBefore + 1) + + manager.removeSession(for: id) + } + + @Test("Removing a session increments both connectionListVersion and connectionStatusVersion") + func removeSessionIncrementsBothCounters() { + let (id, session) = makeSession() + let manager = DatabaseManager.shared + manager.injectSession(session, for: id) + + let listBefore = manager.connectionListVersion + let statusBefore = manager.connectionStatusVersion + + manager.removeSession(for: id) + + #expect(manager.connectionListVersion == listBefore + 1) + #expect(manager.connectionStatusVersion == statusBefore + 1) + } + + @Test("Updating a session in-place increments connectionStatusVersion but not connectionListVersion") + func updateSessionIncrementsOnlyStatusVersion() { + let (id, session) = makeSession() + let manager = DatabaseManager.shared + manager.injectSession(session, for: id) + + let listBefore = manager.connectionListVersion + let statusBefore = manager.connectionStatusVersion + + manager.updateSession(id) { session in + session.status = .connected + } + + #expect(manager.connectionListVersion == listBefore) + #expect(manager.connectionStatusVersion == statusBefore + 1) + + manager.removeSession(for: id) + } + + @Test("sessionVersion returns connectionStatusVersion for backward compatibility") + func sessionVersionBackwardCompat() { + let manager = DatabaseManager.shared + #expect(manager.sessionVersion == manager.connectionStatusVersion) + + let (id, session) = makeSession() + manager.injectSession(session, for: id) + + #expect(manager.sessionVersion == manager.connectionStatusVersion) + + manager.updateSession(id) { session in + session.status = .connected + } + + #expect(manager.sessionVersion == manager.connectionStatusVersion) + + manager.removeSession(for: id) + } + + @Test("Multiple rapid mutations increment counters correctly") + func rapidMutationsIncrementCorrectly() { + let manager = DatabaseManager.shared + let listBefore = manager.connectionListVersion + let statusBefore = manager.connectionStatusVersion + + let (id1, session1) = makeSession() + let (id2, session2) = makeSession() + + manager.injectSession(session1, for: id1) + manager.injectSession(session2, for: id2) + + manager.updateSession(id1) { $0.status = .connected } + manager.updateSession(id2) { $0.status = .connected } + manager.updateSession(id1) { $0.status = .error("test") } + + #expect(manager.connectionListVersion == listBefore + 2) + #expect(manager.connectionStatusVersion == statusBefore + 5) + + manager.removeSession(for: id1) + manager.removeSession(for: id2) + } + + @Test("Adding then removing the same session increments both counters twice") + func addRemoveSameSessionIncrementsTwice() { + let manager = DatabaseManager.shared + let listBefore = manager.connectionListVersion + let statusBefore = manager.connectionStatusVersion + + let (id, session) = makeSession() + + manager.injectSession(session, for: id) + manager.removeSession(for: id) + + #expect(manager.connectionListVersion == listBefore + 2) + #expect(manager.connectionStatusVersion == statusBefore + 2) + } + + @Test("Initial counter values are zero before any test mutations") + func initialValuesConsistent() { + let manager = DatabaseManager.shared + #expect(manager.connectionListVersion >= 0) + #expect(manager.connectionStatusVersion >= 0) + #expect(manager.connectionStatusVersion >= manager.connectionListVersion) + } +} diff --git a/TableProTests/Core/SSH/SSHTunnelManagerHealthTests.swift b/TableProTests/Core/SSH/SSHTunnelManagerHealthTests.swift new file mode 100644 index 00000000..c8e834d4 --- /dev/null +++ b/TableProTests/Core/SSH/SSHTunnelManagerHealthTests.swift @@ -0,0 +1,120 @@ +// +// SSHTunnelManagerHealthTests.swift +// TableProTests +// +// Regression tests for SSHTunnelManager termination handlers (P2-1). +// Validates health check configuration and process tree utilities. +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("SSHTunnelManager Health") +struct SSHTunnelManagerHealthTests { + // MARK: - Descendant Process Tree + + @Test("descendantProcessIds returns root when no children exist") + func descendantProcessIdsRootOnly() { + let result = SSHTunnelManager.descendantProcessIds( + rootProcessId: 42, + parentProcessIds: [100: 200, 300: 400] + ) + #expect(result == [42]) + } + + @Test("descendantProcessIds finds deeply nested children") + func descendantProcessIdsDeeplyNested() { + let result = SSHTunnelManager.descendantProcessIds( + rootProcessId: 1, + parentProcessIds: [2: 1, 3: 2, 4: 3, 5: 4] + ) + #expect(result == [1, 2, 3, 4, 5]) + } + + @Test("descendantProcessIds handles branching process tree") + func descendantProcessIdsBranching() { + let result = SSHTunnelManager.descendantProcessIds( + rootProcessId: 1, + parentProcessIds: [10: 1, 11: 1, 20: 10, 21: 10, 30: 11] + ) + #expect(result == [1, 10, 11, 20, 21, 30]) + } + + @Test("descendantProcessIds with empty parent map returns root only") + func descendantProcessIdsEmptyMap() { + let result = SSHTunnelManager.descendantProcessIds( + rootProcessId: 99, + parentProcessIds: [:] + ) + #expect(result == [99]) + } + + @Test("descendantProcessIds is idempotent across multiple calls") + func descendantProcessIdsIdempotent() { + let parentMap: [Int32: Int32] = [2: 1, 3: 1, 4: 2] + + let result1 = SSHTunnelManager.descendantProcessIds(rootProcessId: 1, parentProcessIds: parentMap) + let result2 = SSHTunnelManager.descendantProcessIds(rootProcessId: 1, parentProcessIds: parentMap) + + #expect(result1 == result2) + } + + // MARK: - Port Bind Failure Classification + + @Test("isLocalPortBindFailure detects all known bind failure patterns") + func bindFailurePatterns() { + #expect(SSHTunnelManager.isLocalPortBindFailure("Address already in use")) + #expect(SSHTunnelManager.isLocalPortBindFailure("cannot listen to port: 60000")) + #expect(SSHTunnelManager.isLocalPortBindFailure("Could not request local forwarding.")) + #expect(SSHTunnelManager.isLocalPortBindFailure("port forwarding failed for listen port 60123")) + } + + @Test("isLocalPortBindFailure is case-insensitive") + func bindFailureCaseInsensitive() { + #expect(SSHTunnelManager.isLocalPortBindFailure("ADDRESS ALREADY IN USE")) + #expect(SSHTunnelManager.isLocalPortBindFailure("Cannot Listen To Port")) + } + + @Test("isLocalPortBindFailure returns false for unrelated SSH errors") + func nonBindFailures() { + #expect(!SSHTunnelManager.isLocalPortBindFailure("Permission denied")) + #expect(!SSHTunnelManager.isLocalPortBindFailure("Connection refused")) + #expect(!SSHTunnelManager.isLocalPortBindFailure("Host key verification failed")) + #expect(!SSHTunnelManager.isLocalPortBindFailure("")) + } + + // MARK: - SSHTunnelError Description + + @Test("SSHTunnelError.noAvailablePort has a localized description") + func noAvailablePortDescription() { + let error = SSHTunnelError.noAvailablePort + #expect(error.errorDescription != nil) + #expect(error.errorDescription?.isEmpty == false) + } + + @Test("SSHTunnelError.authenticationFailed has a localized description") + func authenticationFailedDescription() { + let error = SSHTunnelError.authenticationFailed + #expect(error.errorDescription != nil) + } + + @Test("SSHTunnelError.tunnelAlreadyExists includes connection ID in description") + func tunnelAlreadyExistsDescription() { + let id = UUID() + let error = SSHTunnelError.tunnelAlreadyExists(id) + #expect(error.errorDescription?.contains(id.uuidString) == true) + } + + @Test("SSHTunnelError.connectionTimeout has a localized description") + func connectionTimeoutDescription() { + let error = SSHTunnelError.connectionTimeout + #expect(error.errorDescription != nil) + } + + @Test("SSHTunnelError.sshCommandNotFound has a localized description") + func sshCommandNotFoundDescription() { + let error = SSHTunnelError.sshCommandNotFound + #expect(error.errorDescription != nil) + } +} diff --git a/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift b/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift new file mode 100644 index 00000000..8adc8d94 --- /dev/null +++ b/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift @@ -0,0 +1,215 @@ +// +// RowOperationsManagerCopyTests.swift +// TableProTests +// +// Regression tests for RowOperationsManager copy optimization (P2-6). +// Validates TSV formatting, NULL handling, and large-row correctness. +// + +import Foundation +@testable import TablePro +import Testing + +private final class MockClipboardProvider: ClipboardProvider { + var lastWrittenText: String? + var textToRead: String? + + func readText() -> String? { textToRead } + + func writeText(_ text: String) { + lastWrittenText = text + } + + var hasText: Bool { textToRead != nil } +} + +@MainActor +@Suite("RowOperationsManager Copy") +struct RowOperationsManagerCopyTests { + // MARK: - Helpers + + private func makeManager() -> (RowOperationsManager, DataChangeManager) { + let changeManager = DataChangeManager() + changeManager.configureForTable( + tableName: "users", + columns: ["id", "name", "email"], + primaryKeyColumn: "id", + databaseType: .mysql + ) + let manager = RowOperationsManager(changeManager: changeManager) + return (manager, changeManager) + } + + private func copyAndCapture( + manager: RowOperationsManager, + indices: Set, + rows: [QueryResultRow], + columns: [String] = [], + includeHeaders: Bool = false + ) -> String? { + let clipboard = MockClipboardProvider() + ClipboardService.shared = clipboard + manager.copySelectedRowsToClipboard( + selectedIndices: indices, + resultRows: rows, + columns: columns, + includeHeaders: includeHeaders + ) + return clipboard.lastWrittenText + } + + // MARK: - Single Row TSV + + @Test("Single row copy produces tab-separated values") + func singleRowTSV() { + let (manager, _) = makeManager() + let rows = [QueryResultRow(id: 0, values: ["1", "Alice", "alice@test.com"])] + + let result = copyAndCapture(manager: manager, indices: [0], rows: rows) + + #expect(result == "1\tAlice\talice@test.com") + } + + // MARK: - Multiple Rows + + @Test("Multiple rows separated by newlines in TSV format") + func multipleRowsTSV() { + let (manager, _) = makeManager() + let rows = [ + QueryResultRow(id: 0, values: ["1", "Alice", "a@test.com"]), + QueryResultRow(id: 1, values: ["2", "Bob", "b@test.com"]), + ] + + let result = copyAndCapture(manager: manager, indices: [0, 1], rows: rows) + + #expect(result == "1\tAlice\ta@test.com\n2\tBob\tb@test.com") + } + + // MARK: - NULL Handling + + @Test("NULL values rendered as literal NULL string") + func nullValuesRenderedAsNullString() { + let (manager, _) = makeManager() + let rows = [QueryResultRow(id: 0, values: [nil, "Alice", nil])] + + let result = copyAndCapture(manager: manager, indices: [0], rows: rows) + + #expect(result == "NULL\tAlice\tNULL") + } + + @Test("Mixed NULL and non-NULL values in same row") + func mixedNullAndNonNull() { + let (manager, _) = makeManager() + let rows = [ + QueryResultRow(id: 0, values: ["1", nil, "a@test.com"]), + QueryResultRow(id: 1, values: [nil, "Bob", nil]), + ] + + let result = copyAndCapture(manager: manager, indices: [0, 1], rows: rows) + + let lines = result?.components(separatedBy: "\n") + #expect(lines?.count == 2) + #expect(lines?[0] == "1\tNULL\ta@test.com") + #expect(lines?[1] == "NULL\tBob\tNULL") + } + + // MARK: - Empty Selection + + @Test("Empty selection produces no clipboard write") + func emptySelectionNoWrite() { + let (manager, _) = makeManager() + let rows = TestFixtures.makeQueryResultRows(count: 3) + let clipboard = MockClipboardProvider() + ClipboardService.shared = clipboard + + manager.copySelectedRowsToClipboard( + selectedIndices: [], + resultRows: rows + ) + + #expect(clipboard.lastWrittenText == nil) + } + + // MARK: - Large Row Count + + @Test("Large row count produces correct first and last rows") + func largeRowCount() { + let (manager, _) = makeManager() + let count = 1_000 + let rows = (0.. Self.maxHighlightLength + 1_000) + + let textView = makeHighlightedJSONView(json: longJson) + + guard let textStorage = textView.textStorage else { + Issue.record("No text storage") + return + } + + let positionBeyondCap = Self.maxHighlightLength + 500 + guard positionBeyondCap < textStorage.length else { + Issue.record("Text too short") + return + } + + var hasHighlightBeyondCap = false + let beyondRange = NSRange(location: positionBeyondCap, length: min(500, textStorage.length - positionBeyondCap)) + textStorage.enumerateAttribute(.foregroundColor, in: beyondRange) { value, _, _ in + if let color = value as? NSColor, color != .labelColor { + hasHighlightBeyondCap = true + } + } + + #expect(!hasHighlightBeyondCap, "JSON beyond 10K should not have syntax highlighting") + } + + @Test("JSON within cap region is highlighted for long text") + func jsonWithinCapHighlighted() { + let chunk = "{\"key\": \"value\"}, " + let repeatCount = (Self.maxHighlightLength / (chunk as NSString).length) + 200 + let longJson = String(repeating: chunk, count: repeatCount) + + let textView = makeHighlightedJSONView(json: longJson) + + guard let textStorage = textView.textStorage else { + Issue.record("No text storage") + return + } + + var hasHighlightWithinCap = false + let withinRange = NSRange(location: 0, length: min(100, textStorage.length)) + textStorage.enumerateAttribute(.foregroundColor, in: withinRange) { value, _, _ in + if let color = value as? NSColor, color != .labelColor { + hasHighlightWithinCap = true + } + } + + #expect(hasHighlightWithinCap, "JSON within 10K should have syntax highlighting") + } + + @Test("Empty JSON does not crash") + func jsonEmptyNoCrash() { + let textView = makeHighlightedJSONView(json: "") + #expect(textView.string.isEmpty) + } + + // MARK: - AIChatCodeBlockView SQL/JS Highlighting Cap + + @Test("Large SQL code block AttributedString caps highlighting at 10K") + func aiChatSQLCapsAt10K() { + let filler = String(repeating: "x", count: Self.maxHighlightLength) + let code = filler + " SELECT id FROM users" + let attributed = highlightedSQLViaPatterns(code) + + let keywordPos = Self.maxHighlightLength + 1 + let nsCode = code as NSString + guard keywordPos + 6 <= nsCode.length else { + Issue.record("Code too short") + return + } + + let cappedRange = NSRange(location: 0, length: min(nsCode.length, Self.maxHighlightLength)) + let keywordRange = NSRange(location: keywordPos, length: 6) + let keywordIntersection = NSIntersectionRange(cappedRange, keywordRange) + + #expect(keywordIntersection.length == 0, "SELECT keyword should be outside the capped range") + } + + @Test("Large JavaScript code block AttributedString caps highlighting at 10K") + func aiChatJSCapsAt10K() { + let filler = String(repeating: "x", count: Self.maxHighlightLength) + let code = filler + " db.collection.find({})" + let nsCode = code as NSString + + let cappedRange = NSRange(location: 0, length: min(nsCode.length, Self.maxHighlightLength)) + let dbPos = Self.maxHighlightLength + 1 + let dbRange = NSRange(location: dbPos, length: 2) + let intersection = NSIntersectionRange(cappedRange, dbRange) + + #expect(intersection.length == 0, "db keyword should be outside the capped range") + } + + @Test("AIChatCodeBlockView does not crash with large SQL") + func aiChatLargeSQLNoCrash() { + let largeSql = "SELECT " + String(repeating: "col, ", count: Self.maxHighlightLength / 5) + _ = AIChatCodeBlockView(code: largeSql, language: "sql") + } + + @Test("AIChatCodeBlockView does not crash with large JavaScript") + func aiChatLargeJSNoCrash() { + let largeJs = "db.collection.find(" + String(repeating: "\"key\", ", count: Self.maxHighlightLength / 7) + "{})" + _ = AIChatCodeBlockView(code: largeJs, language: "javascript") + } + + @Test("AIChatCodeBlockView does not crash with empty code") + func aiChatEmptyNoCrash() { + _ = AIChatCodeBlockView(code: "", language: "sql") + _ = AIChatCodeBlockView(code: "", language: "javascript") + _ = AIChatCodeBlockView(code: "", language: nil) + } + + // MARK: - JSON Pattern Capping Contract + + @Test("JSONHighlightPatterns with capped range produces fewer matches than full range") + func jsonPatternsCappedVsFull() { + let chunk = "\"hello\" " + let repeatCount = (Self.maxHighlightLength / (chunk as NSString).length) + 200 + let longText = String(repeating: chunk, count: repeatCount) + let nsText = longText as NSString + #expect(nsText.length > Self.maxHighlightLength) + + let cappedRange = NSRange(location: 0, length: Self.maxHighlightLength) + let fullRange = NSRange(location: 0, length: nsText.length) + + let cappedMatches = JSONHighlightPatterns.string.matches(in: longText, range: cappedRange) + let fullMatches = JSONHighlightPatterns.string.matches(in: longText, range: fullRange) + + #expect(cappedMatches.count < fullMatches.count, "Capped range should yield fewer matches") + + for match in cappedMatches { + let matchEnd = match.range.location + match.range.length + #expect(matchEnd <= Self.maxHighlightLength, "All capped matches must end within 10K") + } + } + + // MARK: - Helpers + + private func makeHighlightedSQLView(sql: String) -> (NSTextView, NSScrollView) { + let scrollView = NSTextView.scrollableTextView() + guard let textView = scrollView.documentView as? NSTextView else { + return (NSTextView(), scrollView) + } + + textView.isEditable = false + textView.font = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular) + textView.textColor = NSColor.labelColor + textView.string = sql + + if !sql.isEmpty { + applySQLHighlightingWithCap(to: textView) + } + + return (textView, scrollView) + } + + private func makeHighlightedJSONView(json: String) -> NSTextView { + let textView = NSTextView() + textView.font = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular) + textView.textColor = NSColor.labelColor + textView.string = json + + if !json.isEmpty { + applyJSONHighlightingWithCap(to: textView) + } + + return textView + } + + private func applySQLHighlightingWithCap(to textView: NSTextView) { + guard let textStorage = textView.textStorage else { return } + let length = textStorage.length + guard length > 0 else { return } + + let fullRange = NSRange(location: 0, length: length) + let font = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular) + + textStorage.beginEditing() + textStorage.addAttribute(.font, value: font, range: fullRange) + textStorage.addAttribute(.foregroundColor, value: NSColor.labelColor, range: fullRange) + + let highlightLength = min(length, Self.maxHighlightLength) + let highlightRange = NSRange(location: 0, length: highlightLength) + let text = textStorage.string + + // swiftlint:disable force_try + let keywordPattern = try! NSRegularExpression( + pattern: "\\b(SELECT|FROM|WHERE|INSERT|UPDATE|DELETE|CREATE|DROP|ALTER|JOIN|LEFT|RIGHT|" + + "INNER|OUTER|ON|AND|OR|NOT|IN|EXISTS|BETWEEN|LIKE|IS|AS|ORDER|BY|GROUP|" + + "HAVING|LIMIT|OFFSET|UNION|ALL|DISTINCT|INTO|VALUES|SET|TABLE|INDEX|" + + "PRIMARY|KEY|FOREIGN|REFERENCES|DEFAULT|CASE|WHEN|THEN|ELSE|END|" + + "COUNT|SUM|AVG|MIN|MAX|WITH|RECURSIVE)\\b", + options: .caseInsensitive + ) + let stringPattern = try! NSRegularExpression(pattern: "'[^']*'") + let numberPattern = try! NSRegularExpression(pattern: "\\b\\d+\\b") + // swiftlint:enable force_try + + for match in keywordPattern.matches(in: text, range: highlightRange) { + textStorage.addAttribute(.foregroundColor, value: NSColor.systemBlue, range: match.range) + } + for match in stringPattern.matches(in: text, range: highlightRange) { + textStorage.addAttribute(.foregroundColor, value: NSColor.systemRed, range: match.range) + } + for match in numberPattern.matches(in: text, range: highlightRange) { + textStorage.addAttribute(.foregroundColor, value: NSColor.systemPurple, range: match.range) + } + + textStorage.endEditing() + } + + private func applyJSONHighlightingWithCap(to textView: NSTextView) { + guard let textStorage = textView.textStorage else { return } + let length = textStorage.length + guard length > 0 else { return } + + let fullRange = NSRange(location: 0, length: length) + let font = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular) + let content = textStorage.string + + textStorage.beginEditing() + textStorage.addAttribute(.font, value: font, range: fullRange) + textStorage.addAttribute(.foregroundColor, value: NSColor.labelColor, range: fullRange) + + let highlightLength = min(length, Self.maxHighlightLength) + let highlightRange = NSRange(location: 0, length: highlightLength) + + for match in JSONHighlightPatterns.string.matches(in: content, range: highlightRange) { + textStorage.addAttribute(.foregroundColor, value: NSColor.systemRed, range: match.range) + } + + for match in JSONHighlightPatterns.key.matches(in: content, range: highlightRange) { + let captureRange = match.range(at: 1) + if captureRange.location != NSNotFound { + textStorage.addAttribute(.foregroundColor, value: NSColor.systemBlue, range: captureRange) + } + } + + for match in JSONHighlightPatterns.number.matches(in: content, range: highlightRange) { + textStorage.addAttribute(.foregroundColor, value: NSColor.systemPurple, range: match.range) + } + + for match in JSONHighlightPatterns.booleanNull.matches(in: content, range: highlightRange) { + textStorage.addAttribute(.foregroundColor, value: NSColor.systemOrange, range: match.range) + } + + textStorage.endEditing() + } + + private func highlightedSQLViaPatterns(_ code: String) -> NSAttributedString { + let attributed = NSMutableAttributedString( + string: code, + attributes: [ + .font: NSFont.systemFont(ofSize: 12), + .foregroundColor: NSColor.labelColor + ] + ) + + let nsCode = code as NSString + let highlightLength = min(nsCode.length, Self.maxHighlightLength) + let highlightRange = NSRange(location: 0, length: highlightLength) + + // swiftlint:disable:next force_try + let keywordPattern = try! NSRegularExpression( + pattern: "\\b(SELECT|FROM|WHERE|INSERT|UPDATE|DELETE)\\b", + options: .caseInsensitive + ) + + for match in keywordPattern.matches(in: code, range: highlightRange) { + attributed.addAttribute(.foregroundColor, value: NSColor.systemBlue, range: match.range) + } + + return attributed + } +} diff --git a/TableProTests/Views/Editor/SQLCompletionAdapterFuzzyTests.swift b/TableProTests/Views/Editor/SQLCompletionAdapterFuzzyTests.swift new file mode 100644 index 00000000..6c428063 --- /dev/null +++ b/TableProTests/Views/Editor/SQLCompletionAdapterFuzzyTests.swift @@ -0,0 +1,132 @@ +// +// SQLCompletionAdapterFuzzyTests.swift +// TableProTests +// +// Regression tests for SQLCompletionAdapter.fuzzyMatch() method. +// + +@testable import TablePro +import Testing + +@Suite("SQLCompletionAdapter Fuzzy Matching") +struct SQLCompletionAdapterFuzzyTests { + // MARK: - Exact Match + + @Test("Exact match returns true") + func exactMatch() { + #expect(SQLCompletionAdapter.fuzzyMatch(pattern: "select", target: "select") == true) + } + + // MARK: - Prefix Match + + @Test("Prefix match returns true") + func prefixMatch() { + #expect(SQLCompletionAdapter.fuzzyMatch(pattern: "sel", target: "select") == true) + } + + // MARK: - Scattered Match + + @Test("Scattered characters in order returns true") + func scatteredMatch() { + #expect(SQLCompletionAdapter.fuzzyMatch(pattern: "slc", target: "select") == true) + } + + @Test("First and last character match") + func firstAndLastMatch() { + #expect(SQLCompletionAdapter.fuzzyMatch(pattern: "st", target: "select") == true) + } + + @Test("Scattered match across longer string") + func scatteredLongerString() { + #expect(SQLCompletionAdapter.fuzzyMatch(pattern: "usr", target: "users_table") == true) + } + + // MARK: - No Match + + @Test("No matching characters returns false") + func noMatch() { + #expect(SQLCompletionAdapter.fuzzyMatch(pattern: "xyz", target: "select") == false) + } + + @Test("Characters present but in wrong order returns false") + func wrongOrderReturnsFalse() { + #expect(SQLCompletionAdapter.fuzzyMatch(pattern: "tces", target: "select") == false) + } + + // MARK: - Empty Pattern + + @Test("Empty pattern matches anything") + func emptyPatternMatchesAnything() { + #expect(SQLCompletionAdapter.fuzzyMatch(pattern: "", target: "anything") == true) + } + + @Test("Empty pattern matches empty target") + func emptyPatternMatchesEmpty() { + #expect(SQLCompletionAdapter.fuzzyMatch(pattern: "", target: "") == true) + } + + // MARK: - Pattern Longer Than Target + + @Test("Pattern longer than target returns false") + func patternLongerThanTarget() { + #expect(SQLCompletionAdapter.fuzzyMatch(pattern: "selectfromwhere", target: "select") == false) + } + + // MARK: - Case Sensitivity + + @Test("Matching is case-sensitive by default") + func caseSensitive() { + #expect(SQLCompletionAdapter.fuzzyMatch(pattern: "SELECT", target: "select") == false) + } + + @Test("Same case matches") + func sameCaseMatches() { + #expect(SQLCompletionAdapter.fuzzyMatch(pattern: "select", target: "select") == true) + } + + // MARK: - Unicode + + @Test("ASCII pattern against accented target") + func asciiPatternAccentedTarget() { + let result = SQLCompletionAdapter.fuzzyMatch(pattern: "tbl", target: "table") + #expect(result == true) + } + + @Test("Unicode characters in both pattern and target") + func unicodeInBoth() { + let result = SQLCompletionAdapter.fuzzyMatch(pattern: "cafe", target: "cafe") + #expect(result == true) + } + + // MARK: - Large Strings + + @Test("Fuzzy match with large target string") + func largeTargetString() { + let largeTarget = String(repeating: "a", count: 10_000) + "xyz" + #expect(SQLCompletionAdapter.fuzzyMatch(pattern: "xyz", target: largeTarget) == true) + } + + @Test("No match in large target string") + func noMatchLargeTarget() { + let largeTarget = String(repeating: "a", count: 10_000) + #expect(SQLCompletionAdapter.fuzzyMatch(pattern: "xyz", target: largeTarget) == false) + } + + @Test("Pattern at beginning of large target") + func patternAtBeginningOfLargeTarget() { + let largeTarget = "xyz" + String(repeating: "a", count: 10_000) + #expect(SQLCompletionAdapter.fuzzyMatch(pattern: "xyz", target: largeTarget) == true) + } + + // MARK: - Single Characters + + @Test("Single character present returns true") + func singleCharPresent() { + #expect(SQLCompletionAdapter.fuzzyMatch(pattern: "s", target: "select") == true) + } + + @Test("Single character absent returns false") + func singleCharAbsent() { + #expect(SQLCompletionAdapter.fuzzyMatch(pattern: "z", target: "select") == false) + } +} diff --git a/TableProTests/Views/Editor/SQLEditorCoordinatorCleanupTests.swift b/TableProTests/Views/Editor/SQLEditorCoordinatorCleanupTests.swift new file mode 100644 index 00000000..417efa05 --- /dev/null +++ b/TableProTests/Views/Editor/SQLEditorCoordinatorCleanupTests.swift @@ -0,0 +1,85 @@ +// +// SQLEditorCoordinatorCleanupTests.swift +// TableProTests +// +// Regression tests for SQLEditorCoordinator cleanup consolidation (P2-12). +// Validates that destroy() and monitor cleanup are safe and idempotent. +// + +import Foundation +@testable import TablePro +import Testing + +@MainActor +@Suite("SQLEditorCoordinator Cleanup") +struct SQLEditorCoordinatorCleanupTests { + // MARK: - destroy() Safety + + @Test("destroy() on fresh coordinator without prepareCoordinator does not crash") + func destroyWithoutPrepare() { + let coordinator = SQLEditorCoordinator() + coordinator.destroy() + #expect(coordinator.isDestroyed == true) + } + + @Test("destroy() called twice is idempotent and does not crash") + func destroyTwiceIdempotent() { + let coordinator = SQLEditorCoordinator() + coordinator.destroy() + coordinator.destroy() + #expect(coordinator.isDestroyed == true) + } + + @Test("destroy() called three times remains safe") + func destroyTripleCall() { + let coordinator = SQLEditorCoordinator() + coordinator.destroy() + coordinator.destroy() + coordinator.destroy() + #expect(coordinator.isDestroyed == true) + #expect(coordinator.vimMode == .normal) + } + + // MARK: - Post-Destroy State + + @Test("After destroy(), vimMode is .normal") + func postDestroyVimMode() { + let coordinator = SQLEditorCoordinator() + coordinator.destroy() + #expect(coordinator.vimMode == .normal) + } + + @Test("After destroy(), isEditorFirstResponder returns false") + func postDestroyFirstResponder() { + let coordinator = SQLEditorCoordinator() + coordinator.destroy() + #expect(coordinator.isEditorFirstResponder == false) + } + + @Test("After destroy(), controller is nil (never set)") + func postDestroyControllerNil() { + let coordinator = SQLEditorCoordinator() + coordinator.destroy() + #expect(coordinator.controller == nil) + } + + // MARK: - Initial State + + @Test("Fresh coordinator isDestroyed is false") + func freshCoordinatorNotDestroyed() { + let coordinator = SQLEditorCoordinator() + #expect(coordinator.isDestroyed == false) + } + + @Test("Fresh coordinator vimMode is .normal") + func freshCoordinatorVimModeNormal() { + let coordinator = SQLEditorCoordinator() + #expect(coordinator.vimMode == .normal) + } + + @Test("Fresh coordinator has no controller") + func freshCoordinatorNoController() { + let coordinator = SQLEditorCoordinator() + #expect(coordinator.controller == nil) + } +} diff --git a/TableProTests/Views/Results/DataGridCellFactoryPerfTests.swift b/TableProTests/Views/Results/DataGridCellFactoryPerfTests.swift new file mode 100644 index 00000000..5239226b --- /dev/null +++ b/TableProTests/Views/Results/DataGridCellFactoryPerfTests.swift @@ -0,0 +1,190 @@ +// +// DataGridCellFactoryPerfTests.swift +// TableProTests +// +// Regression tests for DataGrid performance optimizations: +// - P2-4: VoiceOver caching (verified via build — static cache replaces per-cell system calls) +// - P1-5: Column width optimization +// - P2-7: Change reapplication version tracking +// + +import Foundation +@testable import TablePro +import Testing + +// MARK: - Column Width Optimization (P1-5) + +@Suite("Column Width Optimization") +@MainActor +struct ColumnWidthOptimizationTests { + @Test("Column width is within min/max bounds") + func columnWidthWithinBounds() { + let factory = DataGridCellFactory() + let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 10) + + for (index, column) in provider.columns.enumerated() { + let width = factory.calculateOptimalColumnWidth( + for: column, + columnIndex: index, + rowProvider: provider + ) + #expect(width >= 60, "Width should be at least 60 (min)") + #expect(width <= 800, "Width should be at most 800 (max)") + } + } + + @Test("Header-only column returns reasonable width") + func headerOnlyColumnWidth() { + let factory = DataGridCellFactory() + let provider = InMemoryRowProvider(rows: [], columns: ["username"]) + + let width = factory.calculateOptimalColumnWidth( + for: "username", + columnIndex: 0, + rowProvider: provider + ) + #expect(width >= 60) + #expect(width <= 800) + } + + @Test("Empty header with no rows returns minimum width") + func emptyHeaderNoRowsReturnsMinWidth() { + let factory = DataGridCellFactory() + let provider = InMemoryRowProvider(rows: [], columns: [""]) + + let width = factory.calculateOptimalColumnWidth( + for: "", + columnIndex: 0, + rowProvider: provider + ) + #expect(width >= 60, "Should return at least minimum width") + } + + @Test("Very long cell content caps width at maximum") + func longContentCapsAtMax() { + let factory = DataGridCellFactory() + let longValue = String(repeating: "X", count: 5_000) + let rows = [QueryResultRow(id: 0, values: [longValue])] + let provider = InMemoryRowProvider(rows: rows, columns: ["data"]) + + let width = factory.calculateOptimalColumnWidth( + for: "data", + columnIndex: 0, + rowProvider: provider + ) + #expect(width <= 800, "Width should be capped at max (800)") + } + + @Test("Many columns still produce valid widths") + func manyColumnsProduceValidWidths() { + let factory = DataGridCellFactory() + let columnCount = 60 + let columns = (0..= 60) + #expect(width <= 800) + } + } + + @Test("Width based on header-only method matches expected bounds") + func headerOnlyWidthCalculation() { + let factory = DataGridCellFactory() + + let shortWidth = factory.calculateColumnWidth(for: "id") + #expect(shortWidth >= 60) + + let longWidth = factory.calculateColumnWidth(for: "a_very_long_column_name_that_is_descriptive") + #expect(longWidth > shortWidth) + #expect(longWidth <= 800) + } + + @Test("Nil cell values do not crash width calculation") + func nilCellValuesSafe() { + let factory = DataGridCellFactory() + let rows = [ + QueryResultRow(id: 0, values: [nil]), + QueryResultRow(id: 1, values: ["hello"]), + QueryResultRow(id: 2, values: [nil]), + ] + let provider = InMemoryRowProvider(rows: rows, columns: ["name"]) + + let width = factory.calculateOptimalColumnWidth( + for: "name", + columnIndex: 0, + rowProvider: provider + ) + #expect(width >= 60) + #expect(width <= 800) + } +} + +// MARK: - Change Reapplication Version Tracking (P2-7) + +@Suite("Change Reapplication Version Tracking") +struct ChangeReapplyVersionTests { + @Test("Version tracking skips redundant work") + func versionTrackingSkipsRedundantWork() { + var lastVersion = 0 + var applyCount = 0 + let currentVersion = 3 + + func reapplyIfNeeded(version: Int) { + guard lastVersion != version else { return } + lastVersion = version + applyCount += 1 + } + + reapplyIfNeeded(version: currentVersion) + #expect(applyCount == 1) + #expect(lastVersion == 3) + + reapplyIfNeeded(version: currentVersion) + #expect(applyCount == 1, "Should skip when version unchanged") + + reapplyIfNeeded(version: 4) + #expect(applyCount == 2, "Should apply when version changes") + #expect(lastVersion == 4) + } + + @Test("Version starts at zero and tracks increments") + func versionStartsAtZeroAndIncrements() { + var lastVersion = 0 + var versions: [Int] = [] + + for v in [0, 1, 1, 2, 2, 2, 3] { + if lastVersion != v { + lastVersion = v + versions.append(v) + } + } + + #expect(versions == [1, 2, 3], "Only version changes should be recorded") + } + + @Test("DataChangeManager reloadVersion increments on cell change") + @MainActor + func dataChangeManagerVersionIncrements() { + let manager = DataChangeManager() + let initialVersion = manager.reloadVersion + + manager.recordCellChange( + rowIndex: 0, + columnIndex: 0, + columnName: "name", + oldValue: "old", + newValue: "new" + ) + + #expect(manager.reloadVersion > initialVersion) + } +} From 97cb6e3164b4791c9e97e7dfeefd0b401f47c0de Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 9 Mar 2026 15:46:15 +0700 Subject: [PATCH 9/9] fix: address PR review feedback for performance fixes --- TablePro/Core/Autocomplete/SQLContextAnalyzer.swift | 4 ++-- TablePro/Core/SSH/SSHTunnelManager.swift | 2 +- TablePro/Views/Main/MainContentView.swift | 2 +- TablePro/Views/Results/DataGridCellFactory.swift | 1 + 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift b/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift index ba975874..b988e7b7 100644 --- a/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift +++ b/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift @@ -857,8 +857,8 @@ final class SQLContextAnalyzer { return .select // Column context } - // Window to last ~5000 chars to avoid O(n) regex on large queries - let windowSize = 5000 + // Window to last N chars to avoid O(n) regex on large queries + let windowSize = 5000 // Also referenced by SQLContextAnalyzerWindowingTests let nsText = textBeforeCursor as NSString let windowedText: String if nsText.length > windowSize { diff --git a/TablePro/Core/SSH/SSHTunnelManager.swift b/TablePro/Core/SSH/SSHTunnelManager.swift index 95881fab..d8f16477 100644 --- a/TablePro/Core/SSH/SSHTunnelManager.swift +++ b/TablePro/Core/SSH/SSHTunnelManager.swift @@ -71,7 +71,7 @@ actor SSHTunnelManager { private func startHealthCheck() { healthCheckTask = Task { [weak self] in while !Task.isCancelled { - try? await Task.sleep(for: .seconds(300)) + try? await Task.sleep(for: .seconds(90)) guard !Task.isCancelled else { break } await self?.checkTunnelHealth() } diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index a1147dca..67ff7370 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -275,7 +275,7 @@ struct MainContentView: View { .onChange(of: currentTab?.resultColumns) { _, newColumns in handleColumnsChange(newColumns: newColumns) } - .onChange(of: DatabaseManager.shared.sessionVersion, initial: true) { _, _ in + .onChange(of: DatabaseManager.shared.connectionStatusVersion, initial: true) { _, _ in let sessions = DatabaseManager.shared.activeSessions guard let session = sessions[connection.id] else { return } if session.isConnected && coordinator.needsLazyLoad { diff --git a/TablePro/Views/Results/DataGridCellFactory.swift b/TablePro/Views/Results/DataGridCellFactory.swift index aa542e8a..c55b2482 100644 --- a/TablePro/Views/Results/DataGridCellFactory.swift +++ b/TablePro/Views/Results/DataGridCellFactory.swift @@ -37,6 +37,7 @@ final class DataGridCellFactory { // MARK: - Cached VoiceOver State private static var cachedVoiceOverEnabled: Bool = NSWorkspace.shared.isVoiceOverEnabled + // Observer lives for app lifetime — no removal needed since DataGridCellFactory is a static singleton cache private static let voiceOverObserver: NSObjectProtocol? = { NotificationCenter.default.addObserver( forName: NSWorkspace.accessibilityDisplayOptionsDidChangeNotification,