From 2c81008e69d237e92a3904746eb53188c54265c9 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 8 Feb 2026 02:00:17 +0700 Subject: [PATCH 1/3] perf: optimize SQL editor for large files and fix tab switching bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Performance: - Rewrite SQLContextAnalyzer to use NSString UTF-16 ops (fixes 100% CPU on 40MB files) - Add local window extraction in CompletionEngine for large docs (>500K chars) - Early-exit in extractQueryAtCursor — stop scanning once cursor's statement found - Batch @Published tab mutations to reduce SwiftUI update cycles - Replace O(n) Swift String ops with O(1) NSString in auto-pairing, import, export - Skip persistence for queries >500KB to prevent UserDefaults freeze - Add SyntaxHighlighter reentrancy guard to prevent didProcessEditing loop - LineNumberView draw() uses layout manager fragments instead of line cache Bug fixes: - Fix queryTextBinding writing to wrong tab during tab switch (use tab ID lookup) - Fix SQL file open showing old tab content (desync version counter on tab switch) - Fix current line highlight ghost on persisted tabs (clip to dirty rect, init tracking) - Open SQL files from Finder while connected --- TablePro.xcodeproj/project.pbxproj | 16 + TablePro/AppDelegate.swift | 44 ++ .../Core/Autocomplete/CompletionEngine.swift | 103 +++- .../Autocomplete/SQLContextAnalyzer.swift | 504 ++++++++++++------ TablePro/Core/Services/ExportService.swift | 2 +- TablePro/Core/Services/ImportService.swift | 3 +- .../Core/Services/QueryExecutionService.swift | 71 +-- .../Core/Services/TabPersistenceService.swift | 13 +- TablePro/Core/Storage/TabStateStorage.swift | 7 + TablePro/Core/Utilities/SQLFileParser.swift | 2 +- TablePro/Info.plist | 49 ++ TablePro/Models/QueryTab.swift | 21 +- TablePro/OpenTableApp.swift | 3 + TablePro/Resources/SQLDocument.icns | Bin 0 -> 51469 bytes .../Connection/ConnectionSidebarHeader.swift | 12 +- TablePro/Views/Editor/EditorCoordinator.swift | 122 ++++- TablePro/Views/Editor/EditorTextView.swift | 393 +++++++++----- TablePro/Views/Editor/LineNumberView.swift | 211 +++++--- TablePro/Views/Editor/NativeTabBar.swift | 21 +- TablePro/Views/Editor/NativeTabBarView.swift | 2 +- TablePro/Views/Editor/SQLEditorView.swift | 19 +- TablePro/Views/Editor/SyntaxHighlighter.swift | 354 ++++++++++-- TablePro/Views/Import/ImportDialog.swift | 2 +- .../Main/Child/MainEditorContentView.swift | 24 +- .../Views/Main/MainContentCoordinator.swift | 95 ++-- .../Main/MainContentNotificationHandler.swift | 38 ++ 26 files changed, 1570 insertions(+), 561 deletions(-) create mode 100644 TablePro/Info.plist create mode 100644 TablePro/Resources/SQLDocument.icns diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index c1f852685..db31ad976 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -10,9 +10,22 @@ 5A1091C72EF17EDC0055EA7C /* TablePro.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TablePro.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 5AF312BE2F36FF7500E86682 /* Exceptions for "TablePro" folder in "TablePro" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 5A1091C62EF17EDC0055EA7C /* TablePro */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ 5A1091C92EF17EDC0055EA7C /* TablePro */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 5AF312BE2F36FF7500E86682 /* Exceptions for "TablePro" folder in "TablePro" target */, + ); path = TablePro; sourceTree = ""; }; @@ -193,6 +206,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ARCHS = arm64; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; @@ -271,6 +285,7 @@ /opt/homebrew/opt/libpq/include, /usr/local/opt/libpq/include, ); + INFOPLIST_FILE = TablePro/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = TablePro; INFOPLIST_KEY_CFBundleIconFile = AppIcon; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; @@ -360,6 +375,7 @@ /opt/homebrew/opt/libpq/include, /usr/local/opt/libpq/include, ); + INFOPLIST_FILE = TablePro/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = TablePro; INFOPLIST_KEY_CFBundleIconFile = AppIcon; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index e11dc58fe..65df22fd0 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -21,6 +21,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { /// Track windows that have been configured to avoid re-applying styles (which causes flicker) private var configuredWindows = Set() + /// URLs queued for opening when no database connection is active yet + private var queuedFileURLs: [URL] = [] + func applicationDockMenu(_ sender: NSApplication) -> NSMenu? { let menu = NSMenu() @@ -97,6 +100,27 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } + func application(_ application: NSApplication, open urls: [URL]) { + let sqlURLs = urls.filter { $0.pathExtension.lowercased() == "sql" } + guard !sqlURLs.isEmpty else { return } + + if DatabaseManager.shared.currentSession != nil { + // Already connected — bring main window to front and open files + for window in NSApp.windows where isMainWindow(window) { + window.makeKeyAndOrderFront(nil) + } + // Close welcome window if it's open (user doesn't need it) + for window in NSApp.windows where isWelcomeWindow(window) { + window.close() + } + NotificationCenter.default.post(name: .openSQLFiles, object: sqlURLs) + } else { + // Not connected — queue and show welcome window + queuedFileURLs.append(contentsOf: sqlURLs) + openWelcomeWindow() + } + } + func applicationDidFinishLaunching(_ notification: Notification) { // Configure windows after app launch configureWelcomeWindow() @@ -128,6 +152,26 @@ class AppDelegate: NSObject, NSApplicationDelegate { name: NSWindow.willCloseNotification, object: nil ) + + // Observe database connection to flush queued .sql files + NotificationCenter.default.addObserver( + self, + selector: #selector(handleDatabaseDidConnect), + name: .databaseDidConnect, + object: nil + ) + } + + @objc + private func handleDatabaseDidConnect() { + guard !queuedFileURLs.isEmpty else { return } + let urls = queuedFileURLs + queuedFileURLs.removeAll() + + // Small delay to allow coordinator/tab manager to finish setup + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + NotificationCenter.default.post(name: .openSQLFiles, object: urls) + } } /// Attempt to auto-reconnect to the last used connection diff --git a/TablePro/Core/Autocomplete/CompletionEngine.swift b/TablePro/Core/Autocomplete/CompletionEngine.swift index 0bca47aa0..537eac828 100644 --- a/TablePro/Core/Autocomplete/CompletionEngine.swift +++ b/TablePro/Core/Autocomplete/CompletionEngine.swift @@ -20,6 +20,13 @@ final class CompletionEngine { private let provider: SQLCompletionProvider + /// Size threshold (in UTF-16 code units) above which we extract a local + /// window around the cursor instead of passing the full document to the + /// context analyzer. 10 KB of UTF-16 ≈ 5 000 characters — more than + /// enough for any single SQL statement the user is editing. + private static let largeDocumentThreshold = 500_000 + private static let localWindowRadius = 5_000 + // MARK: - Initialization init(schemaProvider: SQLSchemaProvider) { @@ -34,10 +41,31 @@ final class CompletionEngine { text: String, cursorPosition: Int ) async -> CompletionContext? { - // Get completions from provider + let nsText = text as NSString + let textLength = nsText.length + + // For large documents, extract a local window around the cursor so the + // context analyzer only processes ~10 KB instead of the full document. + let analysisText: String + let windowOffset: Int + + if textLength > Self.largeDocumentThreshold { + let (window, offset) = extractLocalWindow( + from: nsText, cursorPosition: cursorPosition + ) + analysisText = window + windowOffset = offset + } else { + analysisText = text + windowOffset = 0 + } + + let adjustedCursor = cursorPosition - windowOffset + + // Get completions from provider (uses the potentially windowed text) let (items, context) = await provider.getCompletions( - text: text, - cursorPosition: cursorPosition + text: analysisText, + cursorPosition: adjustedCursor ) // Don't return empty results @@ -45,15 +73,74 @@ final class CompletionEngine { return nil } - // Calculate replacement range - let replaceStart = context.prefixRange.lowerBound - let replaceEnd = context.prefixRange.upperBound - let replacementRange = NSRange(location: replaceStart, length: replaceEnd - replaceStart) + // Calculate replacement range — translate back to original document + // positions by adding windowOffset + let replaceStart = context.prefixRange.lowerBound + windowOffset + let replaceEnd = context.prefixRange.upperBound + windowOffset + let replacementRange = NSRange( + location: replaceStart, length: replaceEnd - replaceStart + ) + + // Build a context with prefixRange adjusted back to original positions + let adjustedContext = SQLContext( + clauseType: context.clauseType, + prefix: context.prefix, + prefixRange: replaceStart.. (window: String, offset: Int) { + let textLength = nsText.length + let radius = Self.localWindowRadius + + // Raw window bounds + var windowStart = max(0, cursorPosition - radius) + let windowEnd = min(textLength, cursorPosition + radius) + + // Try to extend windowStart backwards to find a semicolon (statement + // boundary) so the analyzer gets a complete statement + if windowStart > 0 { + let searchRange = NSRange( + location: windowStart, length: cursorPosition - windowStart + ) + let semiRange = nsText.range( + of: ";", + options: .backwards, + range: searchRange + ) + if semiRange.location != NSNotFound { + // Start just after the semicolon + windowStart = semiRange.location + 1 + } + } + + let extractRange = NSRange( + location: windowStart, length: windowEnd - windowStart ) + let window = nsText.substring(with: extractRange) + return (window, windowStart) } } diff --git a/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift b/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift index e72891a65..fc064c6bd 100644 --- a/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift +++ b/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift @@ -89,6 +89,26 @@ struct SQLContext { /// Analyzes SQL query to determine completion context final class SQLContextAnalyzer { + // MARK: - UTF-16 Character Constants + + private static let singleQuote = UInt16(UnicodeScalar("'").value) + private static let doubleQuote = UInt16(UnicodeScalar("\"").value) + private static let backslash = UInt16(UnicodeScalar("\\").value) + private static let semicolon = UInt16(UnicodeScalar(";").value) + private static let dash = UInt16(UnicodeScalar("-").value) + private static let newline = UInt16(UnicodeScalar("\n").value) + private static let openParen = UInt16(UnicodeScalar("(").value) + private static let closeParen = UInt16(UnicodeScalar(")").value) + private static let dot = UInt16(UnicodeScalar(".").value) + private static let backtick = UInt16(UnicodeScalar("`").value) + private static let underscore = UInt16(UnicodeScalar("_").value) + private static let comma = UInt16(UnicodeScalar(",").value) + private static let space = UInt16(UnicodeScalar(" ").value) + private static let tab = UInt16(UnicodeScalar("\t").value) + private static let cr = UInt16(UnicodeScalar("\r").value) + private static let slash = UInt16(UnicodeScalar("/").value) + private static let star = UInt16(UnicodeScalar("*").value) + // MARK: - Cached Regex Patterns (Compiled Once at Class Load) /// Pre-compiled clause detection patterns for performance @@ -96,26 +116,20 @@ final class SQLContextAnalyzer { private static let clauseRegexes: [(regex: NSRegularExpression, clause: SQLClauseType)] = { let patterns: [(String, SQLClauseType)] = [ // DDL patterns (most specific first) - // Match AFTER/BEFORE keyword in ALTER TABLE ADD COLUMN context - ("\\bADD\\s+(?:COLUMN\\s+)?[`\"']?\\w+[`\"']?\\s+\\w+.*?\\b(?:AFTER|BEFORE)(?:\\s+\\w*)?$", .alterTableColumn), - // Match AFTER/BEFORE in general ALTER TABLE context + ("\\bADD\\s+(?:COLUMN\\s+)?[`\"']?\\w+[`\"']?\\s+\\w+.*?\\b(?:AFTER|BEFORE)(?:\\s+\\w*)?$", + .alterTableColumn), ("\\b(?:AFTER|BEFORE)(?:\\s+\\w*)?$", .alterTableColumn), - // Match FIRST keyword for column positioning ("\\bFIRST\\s*$", .alterTable), - // Match ADD keyword immediately after ALTER TABLE tablename (expecting COLUMN, INDEX, etc.) ("\\bALTER\\s+TABLE\\s+[`\"']?\\w+[`\"']?\\s+ADD\\s+\\w*$", .alterTable), - // Match column definition after ADD/MODIFY/CHANGE with data type ( "\\b(?:ADD|MODIFY|CHANGE)\\s+(?:COLUMN\\s+)?[`\"']?\\w+[`\"']?\\s+\\w+(?:\\([^)]*\\))?" + - "(?:\\s+(?:NOT\\s+)?NULL|\\s+DEFAULT(?:\\s+[^\\s]+)?|\\s+AUTO_INCREMENT|\\s+UNSIGNED|\\s+COMMENT(?:\\s+'[^']*')?)*\\s*$", + "(?:\\s+(?:NOT\\s+)?NULL|\\s+DEFAULT(?:\\s+[^\\s]+)?|\\s+AUTO_INCREMENT" + + "|\\s+UNSIGNED|\\s+COMMENT(?:\\s+'[^']*')?)*\\s*$", .columnDef ), - // Match column name after ADD/MODIFY/CHANGE (before data type) - // Only match if we have COLUMN keyword or it's after ALTER TABLE ("\\b(?:ADD|MODIFY|CHANGE)\\s+COLUMN\\s+\\w+\\s*$", .columnDef), - // Match DROP/MODIFY/CHANGE/RENAME with column name - ("\\bALTER\\s+TABLE\\s+[`\"']?\\w+[`\"']?\\s+(?:DROP|MODIFY|CHANGE|RENAME)\\s+(?:COLUMN\\s+)?[`\"']?\\w*[`\"']?\\s*$", .alterTableColumn), - // General ALTER TABLE operations + ("\\bALTER\\s+TABLE\\s+[`\"']?\\w+[`\"']?\\s+(?:DROP|MODIFY|CHANGE|RENAME)" + + "\\s+(?:COLUMN\\s+)?[`\"']?\\w*[`\"']?\\s*$", .alterTableColumn), ("\\bALTER\\s+TABLE\\s+[`\"']?\\w+[`\"']?\\s+\\w*$", .alterTable), ("\\bCREATE\\s+TABLE\\s+[^(]*\\([^)]*$", .createTable), // Enhanced context patterns @@ -134,7 +148,8 @@ final class SQLContextAnalyzer { ("\\bWHERE\\s+[^;]*$", .where_), ("\\bON\\s+[^;]*$", .on), // JOIN patterns - ("(?:LEFT|RIGHT|INNER|OUTER|FULL|CROSS)?\\s*(?:OUTER)?\\s*JOIN\\s+[`\"']?\\w+[`\"']?(?:\\s+(?:AS\\s+)?\\w+)?\\s*$", .join), + ("(?:LEFT|RIGHT|INNER|OUTER|FULL|CROSS)?\\s*(?:OUTER)?\\s*JOIN\\s+[`\"']?\\w+[`\"']?" + + "(?:\\s+(?:AS\\s+)?\\w+)?\\s*$", .join), ("\\bJOIN\\s+[`\"']?\\w*[`\"']?\\s*$", .join), // FROM patterns ("\\bFROM\\s+[`\"']?\\w+[`\"']?(?:\\s+(?:AS\\s+)?\\w+)?\\s*$", .from), @@ -143,7 +158,9 @@ final class SQLContextAnalyzer { ("\\bSELECT\\s+[^;]*$", .select), ] return patterns.compactMap { pattern, clause in - guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) else { + guard let regex = try? NSRegularExpression( + pattern: pattern, options: .caseInsensitive + ) else { assertionFailure("Invalid SQL clause regex pattern: \(pattern)") return nil } @@ -151,13 +168,12 @@ final class SQLContextAnalyzer { } }() - /// Pre-compiled regex for removing strings and comments (force-unwrap safe: simple patterns) + /// Pre-compiled regex for removing strings and comments private static let singleQuoteStringRegex: NSRegularExpression = { if let regex = try? NSRegularExpression(pattern: "'[^']*'") { return regex } assertionFailure("Failed to compile singleQuoteStringRegex - invalid pattern") - // Fallback to a regex that matches nothing return try! NSRegularExpression(pattern: "(?!)") }() @@ -166,7 +182,6 @@ final class SQLContextAnalyzer { return regex } assertionFailure("Failed to compile doubleQuoteStringRegex - invalid pattern") - // Fallback to a regex that matches nothing return try! NSRegularExpression(pattern: "(?!)") }() @@ -175,7 +190,6 @@ final class SQLContextAnalyzer { return regex } assertionFailure("Failed to compile blockCommentRegex - invalid pattern") - // Fallback to a regex that matches nothing return try! NSRegularExpression(pattern: "(?!)") }() @@ -184,21 +198,43 @@ final class SQLContextAnalyzer { return regex } assertionFailure("Failed to compile lineCommentRegex - invalid pattern") - // Fallback to a regex that matches nothing return try! NSRegularExpression(pattern: "(?!)") }() + // MARK: - UTF-16 Helpers + + /// Check if a UTF-16 code unit is a letter or digit (ASCII fast path + fallback) + private static func isIdentifierChar(_ ch: UInt16) -> Bool { + // ASCII letters + if (ch >= 0x41 && ch <= 0x5A) || (ch >= 0x61 && ch <= 0x7A) { return true } + // ASCII digits + if ch >= 0x30 && ch <= 0x39 { return true } + // underscore + if ch == underscore { return true } + return false + } + + /// Check if a UTF-16 code unit is whitespace (space, tab, newline, CR) + private static func isWhitespace(_ ch: UInt16) -> Bool { + ch == space || ch == tab || ch == newline || ch == cr + } + // MARK: - Main Analysis /// Analyze the query at the given cursor position func analyze(query: String, cursorPosition: Int) -> SQLContext { - let safePosition = min(cursorPosition, query.count) + let nsQuery = query as NSString + let safePosition = min(cursorPosition, nsQuery.length) // Extract the current statement for multi-statement queries - let (currentStatement, statementOffset) = extractCurrentStatement(from: query, cursorPosition: safePosition) + let (currentStatement, statementOffset) = extractCurrentStatement( + from: nsQuery, cursorPosition: safePosition + ) let adjustedPosition = safePosition - statementOffset - let textBeforeCursor = String(currentStatement.prefix(max(0, adjustedPosition))) + let nsStatement = currentStatement as NSString + let clampedPosition = max(0, min(adjustedPosition, nsStatement.length)) + let textBeforeCursor = nsStatement.substring(to: clampedPosition) // Check if inside string or comment if isInsideString(textBeforeCursor) { @@ -260,7 +296,11 @@ final class SQLContextAnalyzer { let isAfterComma = checkIfAfterComma(textBeforeCursor) // Determine clause type - let clauseType = determineClauseType(textBeforeCursor: textBeforeCursor, dotPrefix: dotPrefix, currentFunction: currentFunction) + let clauseType = determineClauseType( + textBeforeCursor: textBeforeCursor, + dotPrefix: dotPrefix, + currentFunction: currentFunction + ) return SQLContext( clauseType: clauseType, @@ -279,58 +319,74 @@ final class SQLContextAnalyzer { // MARK: - Multi-Statement Support - /// Extract the current SQL statement containing the cursor - private func extractCurrentStatement(from query: String, cursorPosition: Int) -> (statement: String, offset: Int) { - // Find statement boundaries (semicolons not inside strings/comments) - var statements: [(range: Range, text: String)] = [] - var currentStart = 0 + /// Extract the current SQL statement containing the cursor. + /// Uses NSString UTF-16 character access for O(1) per character instead of + /// O(n) Swift String.index(offsetBy:). + private func extractCurrentStatement( + from nsQuery: NSString, + cursorPosition: Int + ) -> (statement: String, offset: Int) { + let length = nsQuery.length + guard length > 0 else { return ("", 0) } + + // Scan through to find semicolons not inside strings/comments + var statementStart = 0 var inString = false var inComment = false - var prevChar: Character = "\0" + var prevChar: UInt16 = 0 + + // Track the statement that contains the cursor + var foundStatement: String? + var foundOffset = 0 + + for i in 0..= statementStart && cursorPosition < stmtEnd { + foundStatement = nsQuery.substring(with: stmtRange) + foundOffset = statementStart + break + } + statementStart = stmtEnd } - prevChar = char + prevChar = ch } - // Add the last statement (may not end with ;) - if currentStart < query.count { - let startIndex = query.index(query.startIndex, offsetBy: currentStart) - let statementText = String(query[startIndex...]) - statements.append((range: currentStart..= statementStart { + return (nsQuery.substring(with: stmtRange), statementStart) } } // Fallback: return entire query - return (query, 0) + return (nsQuery as String, 0) } // MARK: - CTE Support @@ -340,26 +396,31 @@ final class SQLContextAnalyzer { var cteNames: [String] = [] // Pattern: WITH name AS (...), name2 AS (...) - // Handle both simple and recursive CTEs let pattern = "(?i)\\bWITH\\s+(?:RECURSIVE\\s+)?([\\w]+)\\s+AS\\s*\\(" let commaPattern = "(?i),\\s*([\\w]+)\\s+AS\\s*\\(" + let nsRange = NSRange(location: 0, length: (query as NSString).length) + // Find first CTE if let regex = try? NSRegularExpression(pattern: pattern) { - let range = NSRange(query.startIndex..., in: query) - if let match = regex.firstMatch(in: query, range: range), - let nameRange = Range(match.range(at: 1), in: query) { - cteNames.append(String(query[nameRange])) + if let match = regex.firstMatch(in: query, range: nsRange) { + let nameNSRange = match.range(at: 1) + if nameNSRange.location != NSNotFound { + cteNames.append((query as NSString).substring(with: nameNSRange)) + } } } // Find additional CTEs (comma-separated) if let regex = try? NSRegularExpression(pattern: commaPattern) { - let range = NSRange(query.startIndex..., in: query) - regex.enumerateMatches(in: query, range: range) { match, _, _ in - if let match = match, - let nameRange = Range(match.range(at: 1), in: query) { - cteNames.append(String(query[nameRange])) + regex.enumerateMatches(in: query, range: nsRange) { match, _, _ in + if let match = match { + let nameNSRange = match.range(at: 1) + if nameNSRange.location != NSNotFound { + cteNames.append( + (query as NSString).substring(with: nameNSRange) + ) + } } } } @@ -369,26 +430,30 @@ final class SQLContextAnalyzer { // MARK: - Subquery Support - /// Calculate the nesting level (subquery depth) at cursor position + /// Calculate the nesting level (subquery depth) at cursor position. + /// Uses NSString character-at-index for O(1) access per character. private func calculateNestingLevel(in textBeforeCursor: String) -> Int { + let ns = textBeforeCursor as NSString + let length = ns.length var level = 0 var inString = false - var prevChar: Character = "\0" + var prevChar: UInt16 = 0 - for char in textBeforeCursor { - if char == "'" && prevChar != "\\" { + for i in 0.. String? { + let ns = textBeforeCursor as NSString + let length = ns.length var parenStack: [(position: Int, precedingWord: String?)] = [] var inString = false - var prevChar: Character = "\0" + var prevChar: UInt16 = 0 var currentWord = "" var lastWord: String? - for (index, char) in textBeforeCursor.enumerated() { - if char == "'" && prevChar != "\\" { + for i in 0.. = [ + "COUNT", "SUM", "AVG", "MIN", "MAX", "COALESCE", "IFNULL", + "CONCAT", "SUBSTRING", "UPPER", "LOWER", "NOW", "DATE", + "CAST", "CONVERT", "ROUND", "ABS", "LENGTH", "TRIM", + "GROUP_CONCAT", "DATE_FORMAT", "YEAR", "MONTH", "DAY" + ] + + let subqueryKeywords: Set = [ + "SELECT", "FROM", "WHERE", "IN", "EXISTS", "NOT" + ] - // Either known function or doesn't look like SELECT/subquery keywords if sqlFunctions.contains(upperFunc) || - (!["SELECT", "FROM", "WHERE", "IN", "EXISTS", "NOT"].contains(upperFunc)) { + !subqueryKeywords.contains(upperFunc) { return funcName } } @@ -454,109 +531,173 @@ final class SQLContextAnalyzer { // MARK: - Comma Detection - /// Check if the cursor is immediately after a comma (for multi-column contexts) + /// Check if the cursor is immediately after a comma (for multi-column contexts). + /// Scans backwards using NSString for O(1) character access. private func checkIfAfterComma(_ text: String) -> Bool { - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.hasSuffix(",") + let ns = text as NSString + let length = ns.length + // Scan backwards past whitespace + var i = length - 1 + while i >= 0 { + let ch = ns.character(at: i) + if Self.isWhitespace(ch) { + i -= 1 + continue + } + return ch == Self.comma + } + return false } // MARK: - Helper Methods - /// Check if cursor is inside a string literal + /// Check if cursor is inside a string literal. + /// Uses NSString character-at-index for O(1) access per character. private func isInsideString(_ text: String) -> Bool { + let ns = text as NSString + let length = ns.length var inSingleQuote = false var inDoubleQuote = false - var prevChar: Character = "\0" + var prevChar: UInt16 = 0 - for char in text { - if char == "'" && prevChar != "\\" && !inDoubleQuote { + for i in 0.. Bool { - // Check for line comment - if let lastNewline = text.lastIndex(of: "\n") { - let lineStart = text.index(after: lastNewline) - let currentLine = String(text[lineStart...]) - if currentLine.contains("--"), let dashRange = currentLine.range(of: "--") { - let dashIndex = dashRange.lowerBound - // Check if -- is before current position in line - if currentLine[.. 0 else { return false } + + // Find last newline position using NSString range search + let lastNewlineRange = ns.range( + of: "\n", options: .backwards, range: NSRange(location: 0, length: length) + ) + + if lastNewlineRange.location != NSNotFound { + let lineStart = lastNewlineRange.location + 1 + if lineStart < length { + let currentLineRange = NSRange( + location: lineStart, length: length - lineStart + ) + let currentLine = ns.substring(with: currentLineRange) + let nsLine = currentLine as NSString + let dashRange = nsLine.range(of: "--") + if dashRange.location != NSNotFound { + let beforeDash = nsLine.substring(to: dashRange.location) + if beforeDash.trimmingCharacters(in: .whitespaces).isEmpty || + !beforeDash.contains("'") { + return true + } } } - } else if text.contains("--") { - // First line and contains -- - if let range = text.range(of: "--") { - let before = text[.. closeCount } - /// Extract the current word prefix and any dot prefix (table.column) - private func extractPrefix(from text: String) -> (prefix: String, start: Int, dotPrefix: String?) { - guard !text.isEmpty else { + /// Extract the current word prefix and any dot prefix (table.column). + /// Uses NSString character-at-index for O(1) access instead of Array(text). + private func extractPrefix( + from text: String + ) -> (prefix: String, start: Int, dotPrefix: String?) { + let ns = text as NSString + let length = ns.length + guard length > 0 else { return ("", 0, nil) } - // Find start of current identifier - var prefixStart = text.count + // Scan backwards to find start of identifier + var prefixStart = length var foundDot = false var dotPosition = -1 - // Scan backwards to find start of identifier - let chars = Array(text) - for i in stride(from: chars.count - 1, through: 0, by: -1) { - let char = chars[i] + var i = length - 1 + while i >= 0 { + let ch = ns.character(at: i) - if char == "." && !foundDot { + if ch == Self.dot && !foundDot { foundDot = true dotPosition = i + i -= 1 continue } - if char.isLetter || char.isNumber || char == "_" || char == "`" { - prefixStart = i - } else if foundDot && (char.isLetter || char.isNumber || char == "_" || char == "`") { + if Self.isIdentifierChar(ch) || ch == Self.backtick { prefixStart = i } else { break } - } - let prefix: String - let dotPrefix: String? + i -= 1 + } if foundDot && dotPosition > prefixStart { // Has dot prefix like "users.na" or "u.na" - let beforeDot = String(text[text.index(text.startIndex, offsetBy: prefixStart).. 2, let aliasRange = Range(match.range(at: 2), in: query) { - let aliasCandidate = String(query[aliasRange]) - // Skip SQL keywords as aliases + // Skip SQL keywords + guard !sqlKeywords.contains(tableName.uppercased()) else { return } + + // Group 2: alias (optional) + var alias: String? + if match.numberOfRanges > 2 { + let aliasNSRange = match.range(at: 2) + if aliasNSRange.location != NSNotFound { + let aliasCandidate = (query as NSString).substring( + with: aliasNSRange + ) if !sqlKeywords.contains(aliasCandidate.uppercased()) { alias = aliasCandidate } } + } - // Don't add duplicates - let ref = TableReference(tableName: tableName, alias: alias) - if !references.contains(ref) { - references.append(ref) - } + // Don't add duplicates + let ref = TableReference(tableName: tableName, alias: alias) + if !references.contains(ref) { + references.append(ref) } } } @@ -620,7 +768,6 @@ final class SQLContextAnalyzer { /// Pre-compiled regex for extracting table name from ALTER TABLE statements private static let alterTableRegex: NSRegularExpression? = { - // Pattern: ALTER TABLE tablename (supports optional quoting) let pattern = "(?i)\\bALTER\\s+TABLE\\s+[`\"']?(\\w+)[`\"']?" return try? NSRegularExpression(pattern: pattern) }() @@ -629,17 +776,23 @@ final class SQLContextAnalyzer { private func extractAlterTableName(from query: String) -> String? { guard let regex = Self.alterTableRegex else { return nil } - let range = NSRange(query.startIndex..., in: query) - if let match = regex.firstMatch(in: query, range: range), - let tableRange = Range(match.range(at: 1), in: query) { - return String(query[tableRange]) + let nsRange = NSRange(location: 0, length: (query as NSString).length) + if let match = regex.firstMatch(in: query, range: nsRange) { + let tableNSRange = match.range(at: 1) + if tableNSRange.location != NSNotFound { + return (query as NSString).substring(with: tableNSRange) + } } return nil } /// Determine the clause type based on text before cursor - private func determineClauseType(textBeforeCursor: String, dotPrefix: String?, currentFunction: String? = nil) -> SQLClauseType { + private func determineClauseType( + textBeforeCursor: String, + dotPrefix: String?, + currentFunction: String? = nil + ) -> SQLClauseType { // If we have a dot prefix, we're looking for columns if dotPrefix != nil { return .select // Column context @@ -656,7 +809,7 @@ final class SQLContextAnalyzer { let cleaned = removeStringsAndComments(from: upper) // Use pre-compiled regex patterns for performance - let range = NSRange(cleaned.startIndex..., in: cleaned) + let range = NSRange(location: 0, length: (cleaned as NSString).length) for (regex, clause) in Self.clauseRegexes { if regex.firstMatch(in: cleaned, range: range) != nil { return clause @@ -670,28 +823,27 @@ final class SQLContextAnalyzer { private func removeStringsAndComments(from text: String) -> String { var result = text - // Use pre-compiled regex patterns for performance result = Self.singleQuoteStringRegex.stringByReplacingMatches( in: result, - range: NSRange(result.startIndex..., in: result), + range: NSRange(location: 0, length: (result as NSString).length), withTemplate: "''" ) result = Self.doubleQuoteStringRegex.stringByReplacingMatches( in: result, - range: NSRange(result.startIndex..., in: result), + range: NSRange(location: 0, length: (result as NSString).length), withTemplate: "\"\"" ) result = Self.blockCommentRegex.stringByReplacingMatches( in: result, - range: NSRange(result.startIndex..., in: result), + range: NSRange(location: 0, length: (result as NSString).length), withTemplate: "" ) result = Self.lineCommentRegex.stringByReplacingMatches( in: result, - range: NSRange(result.startIndex..., in: result), + range: NSRange(location: 0, length: (result as NSString).length), withTemplate: "" ) diff --git a/TablePro/Core/Services/ExportService.swift b/TablePro/Core/Services/ExportService.swift index 809ed2574..a92f8b682 100644 --- a/TablePro/Core/Services/ExportService.swift +++ b/TablePro/Core/Services/ExportService.swift @@ -590,7 +590,7 @@ final class ExportService: ObservableObject { /// - Control characters U+0000 to U+001F (required by spec) private func escapeJSONString(_ string: String) -> String { var result = "" - result.reserveCapacity(string.count) + result.reserveCapacity((string as NSString).length) for char in string { switch char { case "\"": result += "\\\"" diff --git a/TablePro/Core/Services/ImportService.swift b/TablePro/Core/Services/ImportService.swift index 66ce12cb0..ba8a25d70 100644 --- a/TablePro/Core/Services/ImportService.swift +++ b/TablePro/Core/Services/ImportService.swift @@ -160,7 +160,8 @@ final class ImportService: ObservableObject { for try await (statement, lineNumber) in stream { try checkCancellation() - currentStatement = statement.count > 50 ? String(statement.prefix(50)) + "..." : statement + let nsStmt = statement as NSString + currentStatement = nsStmt.length > 50 ? nsStmt.substring(to: 50) + "..." : statement currentStatementIndex = executedCount + 1 let statementStartTime = Date() diff --git a/TablePro/Core/Services/QueryExecutionService.swift b/TablePro/Core/Services/QueryExecutionService.swift index ef6e4ee10..cd307cc58 100644 --- a/TablePro/Core/Services/QueryExecutionService.swift +++ b/TablePro/Core/Services/QueryExecutionService.swift @@ -147,60 +147,61 @@ final class QueryExecutionService: ObservableObject { /// Extract the SQL statement at the cursor position (semicolon-delimited) /// Enables TablePlus-like behavior: execute only the current query func extractQueryAtCursor(from fullQuery: String, at position: Int) -> String { - let trimmed = fullQuery.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return trimmed } + let nsQuery = fullQuery as NSString + let length = nsQuery.length + guard length > 0 else { return "" } + + // Fast check: if no semicolons, return the full query trimmed. + // Uses NSString range search (C-level speed) instead of Swift String.contains. + guard nsQuery.range(of: ";").location != NSNotFound else { + return fullQuery.trimmingCharacters(in: .whitespacesAndNewlines) + } - // If no semicolons, return the entire query - guard trimmed.contains(";") else { return trimmed } + let singleQuote = UInt16(UnicodeScalar("'").value) + let doubleQuote = UInt16(UnicodeScalar("\"").value) + let semicolonChar = UInt16(UnicodeScalar(";").value) - // Split by semicolon but keep track of positions - var statements: [(text: String, range: Range)] = [] + let safePosition = min(max(0, position), length) var currentStart = 0 var inString = false - var stringChar: Character = "\"" + var stringCharVal: UInt16 = 0 + + // Scan through characters, stopping as soon as we find the statement + // containing the cursor. Avoids scanning the entire file. + for i in 0..= currentStart && safePosition <= stmtEnd { + let stmtRange = NSRange(location: currentStart, length: i - currentStart) + return nsQuery.substring(with: stmtRange) + .trimmingCharacters(in: .whitespacesAndNewlines) } - currentStart = i + 1 - } - } - - // Don't forget the last statement (may not end with ;) - if currentStart < fullQuery.count { - let startIndex = fullQuery.index(fullQuery.startIndex, offsetBy: currentStart) - let remaining = String(fullQuery[startIndex...]).trimmingCharacters(in: .whitespacesAndNewlines) - if !remaining.isEmpty { - statements.append((text: remaining, range: currentStart..? + private var lastQueryDebounceTask: Task? private let connectionId: UUID // MARK: - Initialization @@ -225,8 +226,18 @@ final class TabPersistenceService: ObservableObject { TabStateStorage.shared.loadLastQuery(for: connectionId) } - /// Save last query for this connection + /// Save last query for this connection (synchronous - use saveLastQueryDebounced for per-keystroke calls) func saveLastQuery(_ query: String) { TabStateStorage.shared.saveLastQuery(query, for: connectionId) } + + /// Save last query with debouncing to avoid blocking I/O on every keystroke + func saveLastQueryDebounced(_ query: String) { + lastQueryDebounceTask?.cancel() + lastQueryDebounceTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: Self.saveDebounceDelay) + guard !Task.isCancelled, !isDismissing else { return } + TabStateStorage.shared.saveLastQuery(query, for: connectionId) + } + } } diff --git a/TablePro/Core/Storage/TabStateStorage.swift b/TablePro/Core/Storage/TabStateStorage.swift index 82328d04a..83773c7db 100644 --- a/TablePro/Core/Storage/TabStateStorage.swift +++ b/TablePro/Core/Storage/TabStateStorage.swift @@ -64,10 +64,17 @@ final class TabStateStorage { // MARK: - Last Query Memory (TablePlus-style) + /// Maximum query size to persist (500KB). Larger queries (e.g., imported SQL dumps) + /// would block the main thread during UserDefaults I/O. + private static let maxPersistableQuerySize = 500_000 + /// Save the last query text for a connection (persists across tab close/open) func saveLastQuery(_ query: String, for connectionId: UUID) { let key = "com.TablePro.lastquery.\(connectionId.uuidString)" + // Skip persistence for very large queries to avoid main-thread freeze + guard (query as NSString).length < Self.maxPersistableQuerySize else { return } + // Only save non-empty queries (trimmed to avoid saving whitespace-only queries) let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { diff --git a/TablePro/Core/Utilities/SQLFileParser.swift b/TablePro/Core/Utilities/SQLFileParser.swift index 02aa65b40..f1fd17fac 100644 --- a/TablePro/Core/Utilities/SQLFileParser.swift +++ b/TablePro/Core/Utilities/SQLFileParser.swift @@ -29,7 +29,7 @@ final class SQLFileParser: Sendable { /// Characters that can start multi-character sequences (comments, escapes) /// and must not be processed at chunk boundaries without a lookahead character. - private static func isMultiCharSequenceStart(_ char: Character) -> Bool { + private nonisolated static func isMultiCharSequenceStart(_ char: Character) -> Bool { char == "-" || char == "/" || char == "\\" || char == "*" } diff --git a/TablePro/Info.plist b/TablePro/Info.plist new file mode 100644 index 000000000..419a2631c --- /dev/null +++ b/TablePro/Info.plist @@ -0,0 +1,49 @@ + + + + + CFBundleDocumentTypes + + + CFBundleTypeExtensions + + sql + + CFBundleTypeIconFile + SQLDocument + CFBundleTypeName + SQL File + CFBundleTypeRole + Editor + LSHandlerRank + Default + LSItemContentTypes + + com.tablepro.sql + + + + UTImportedTypeDeclarations + + + UTTypeIdentifier + com.tablepro.sql + UTTypeDescription + SQL File + UTTypeIconFile + SQLDocument + UTTypeConformsTo + + public.plain-text + + UTTypeTagSpecification + + public.filename-extension + + sql + + + + + + diff --git a/TablePro/Models/QueryTab.swift b/TablePro/Models/QueryTab.swift index 99277c1e0..3323ce148 100644 --- a/TablePro/Models/QueryTab.swift +++ b/TablePro/Models/QueryTab.swift @@ -319,12 +319,24 @@ struct QueryTab: Identifiable, Equatable { self.tableCreationOptions = nil } + /// Maximum query size to persist (500KB). Queries larger than this are typically + /// imported SQL dumps — serializing them to JSON blocks the main thread. + private static let maxPersistableQuerySize = 500_000 + /// Convert tab to persisted format for storage func toPersistedTab() -> PersistedTab { - PersistedTab( + // Truncate very large queries to prevent JSON encoding from blocking main thread + let persistedQuery: String + if (query as NSString).length > Self.maxPersistableQuerySize { + persistedQuery = "" + } else { + persistedQuery = query + } + + return PersistedTab( id: id, title: title, - query: query, + query: persistedQuery, isPinned: isPinned, tabType: tabType, tableName: tableName @@ -359,9 +371,10 @@ final class QueryTabManager: ObservableObject { // MARK: - Tab Management - func addTab(initialQuery: String? = nil) { + func addTab(initialQuery: String? = nil, title: String? = nil) { let queryCount = tabs.filter { $0.tabType == .query }.count - var newTab = QueryTab(title: "Query \(queryCount + 1)", tabType: .query) + let tabTitle = title ?? "Query \(queryCount + 1)" + var newTab = QueryTab(title: tabTitle, tabType: .query) // If initialQuery provided, use it; otherwise tab starts empty if let query = initialQuery { diff --git a/TablePro/OpenTableApp.swift b/TablePro/OpenTableApp.swift index 7f1ef02dd..012ab6497 100644 --- a/TablePro/OpenTableApp.swift +++ b/TablePro/OpenTableApp.swift @@ -447,6 +447,9 @@ extension Notification.Name { static let previousTab = Notification.Name("previousTab") static let nextTab = Notification.Name("nextTab") + // File opening notifications + static let openSQLFiles = Notification.Name("openSQLFiles") + // Window lifecycle notifications static let mainWindowWillClose = Notification.Name("mainWindowWillClose") static let openMainWindow = Notification.Name("openMainWindow") diff --git a/TablePro/Resources/SQLDocument.icns b/TablePro/Resources/SQLDocument.icns new file mode 100644 index 0000000000000000000000000000000000000000..df6fddace0722c0f3c0ca4ecae11d35086edbf51 GIT binary patch literal 51469 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 a68L|OVlc+9+AGZf0000 NSTextView feedback loop private var isUpdatingFromTextView: Bool = false + // Debounce text binding updates to avoid O(n) string copy per keystroke + private var textUpdateTask: Task? + + // Generation counter to skip O(n) string comparison in updateTextViewIfNeeded + private var textVersion: UInt64 = 0 + private var lastAppliedTextVersion: UInt64 = 0 + // MARK: - Initialization init( @@ -70,8 +77,22 @@ final class EditorCoordinator: NSObject, NSTextViewDelegate { // Create syntax highlighter syntaxHighlighter = SyntaxHighlighter(textStorage: textStorage) - // Apply initial highlighting - syntaxHighlighter?.highlightFullDocument() + // Attach scroll view for viewport-based highlighting on large documents + if let scrollView = textView.enclosingScrollView { + syntaxHighlighter?.attachScrollView(scrollView, textView: textView) + } + + // Apply initial highlighting. + // For large documents, defer to next run loop so the scroll view has valid bounds + // for viewport-based highlighting. During makeNSView, bounds are still zero. + let textLength = textStorage.length + if textLength > 50_000 { + DispatchQueue.main.async { [weak self] in + self?.syntaxHighlighter?.highlightFullDocument() + } + } else { + syntaxHighlighter?.highlightFullDocument() + } // Set up callbacks textView.onExecute = { [weak self] in @@ -105,6 +126,14 @@ final class EditorCoordinator: NSObject, NSTextViewDelegate { name: .clearSelection, object: nil ) + + // Initialize cursor line tracking state so the first cursor movement + // can properly invalidate the initial highlight. Deferred to next run + // loop because during makeNSView the scroll view bounds are still zero + // and layout may not be ready. + DispatchQueue.main.async { [weak textView] in + textView?.initializeCursorLineTracking() + } } deinit { @@ -112,8 +141,28 @@ final class EditorCoordinator: NSObject, NSTextViewDelegate { } @objc private func handleTabSwitch() { + // Flush pending debounced text update before tab switch + flushTextUpdate() // Dismiss completion when switching tabs to prevent duplicates dismissCompletion() + + // Desync version counter so the next updateTextViewIfNeeded() performs + // string comparison. The flush above triggers @Published which schedules + // a new SwiftUI update cycle — in that cycle, updateNSView passes the + // NEW tab's text, and the desynced versions ensure it actually applies. + textVersion &+= 1 + } + + /// Flush any pending debounced text update immediately + private func flushTextUpdate() { + guard let textView = textView else { return } + textUpdateTask?.cancel() + textUpdateTask = nil + isUpdatingFromTextView = true + textVersion &+= 1 + lastAppliedTextVersion = textVersion + text = textView.string + isUpdatingFromTextView = false } @objc private func handleClearSelection() { @@ -126,11 +175,19 @@ final class EditorCoordinator: NSObject, NSTextViewDelegate { func textDidChange(_ notification: Notification) { guard let textView = notification.object as? NSTextView else { return } - // Update SwiftUI bindings - isUpdatingFromTextView = true - text = textView.string - cursorPosition = textView.selectedRange().location - isUpdatingFromTextView = false + // Debounce both cursor position and text binding updates to avoid + // publishing changes during view updates (which causes undefined behavior). + textUpdateTask?.cancel() + textUpdateTask = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: 100_000_000) // 100ms + guard let self, !Task.isCancelled else { return } + self.isUpdatingFromTextView = true + self.textVersion &+= 1 + self.lastAppliedTextVersion = self.textVersion + self.cursorPosition = textView.selectedRange().location + self.text = textView.string + self.isUpdatingFromTextView = false + } // Note: Syntax highlighting happens automatically via NSTextStorageDelegate // No need to manually trigger it here @@ -142,10 +199,13 @@ final class EditorCoordinator: NSObject, NSTextViewDelegate { func textViewDidChangeSelection(_ notification: Notification) { guard let textView = notification.object as? NSTextView else { return } - // Update cursor position binding - isUpdatingFromTextView = true - cursorPosition = textView.selectedRange().location - isUpdatingFromTextView = false + // Defer cursor position update to avoid publishing during view updates + Task { @MainActor [weak self] in + guard let self else { return } + self.isUpdatingFromTextView = true + self.cursorPosition = textView.selectedRange().location + self.isUpdatingFromTextView = false + } } // MARK: - SwiftUI -> NSTextView Updates @@ -153,13 +213,34 @@ final class EditorCoordinator: NSObject, NSTextViewDelegate { /// Update text view from SwiftUI (prevents feedback loop) func updateTextViewIfNeeded(with newText: String) { guard !isUpdatingFromTextView, - let textView = textView, - textView.string != newText else { return } + let textView = textView else { return } + + // Use version counter to skip O(n) string comparison when the change + // originated from this coordinator (textDidChange increments version) + if lastAppliedTextVersion == textVersion { + return + } + + // External update (e.g. tab switch) — fall back to string comparison + guard textView.string != newText else { + lastAppliedTextVersion = textVersion + return + } // Update without breaking undo stack // Since this is coming from SwiftUI (external update), we use direct assignment textView.string = newText - syntaxHighlighter?.highlightFullDocument() + lastAppliedTextVersion = textVersion + // Highlighting is handled by SyntaxHighlighter's NSTextStorageDelegate, + // which defers to async chunked processing for large documents. + + // Re-initialize cursor line tracking after external text change (e.g. tab + // switch). Setting .string moves the cursor to the end but may not fire + // selectionDidChange through the delegate, leaving lastCursorLineRect + // stale. Defer so layout is ready. + DispatchQueue.main.async { [weak textView] in + textView?.initializeCursorLineTracking() + } } // MARK: - Completion @@ -245,21 +326,22 @@ final class EditorCoordinator: NSObject, NSTextViewDelegate { let text = textView.string let cursorPosition = textView.selectedRange().location + let textLength = (text as NSString).length - guard !text.isEmpty else { return nil } + guard textLength > 0 else { return nil } - // Ensure cursor position is valid - let safePosition = min(max(0, cursorPosition), text.count) + // Ensure cursor position is valid (use O(1) NSString.length) + let safePosition = min(max(0, cursorPosition), textLength) - // Ensure layout is up to date - layoutManager.ensureLayout(forCharacterRange: NSRange(location: 0, length: text.count)) + // Do NOT call ensureLayout — with allowsNonContiguousLayout = true, + // the layout manager handles lazy layout. Forcing layout freezes on large files. // Get glyph count safely let glyphCount = layoutManager.numberOfGlyphs guard glyphCount > 0 else { return nil } // Safe glyph index calculation - let charIndex = min(safePosition, text.count - 1) + let charIndex = min(safePosition, textLength - 1) let glyphIndex = min(layoutManager.glyphIndexForCharacter(at: max(0, charIndex)), glyphCount - 1) // Get line rect safely diff --git a/TablePro/Views/Editor/EditorTextView.swift b/TablePro/Views/Editor/EditorTextView.swift index 13cf1728e..b0a7ca67f 100644 --- a/TablePro/Views/Editor/EditorTextView.swift +++ b/TablePro/Views/Editor/EditorTextView.swift @@ -23,8 +23,10 @@ final class EditorTextView: NSTextView { /// Callback when user clicks at a different position (to dismiss completion) var onClickOutsideCompletion: (() -> Void)? - /// Track the last cursor position for smart invalidation - private var lastCursorLine: Int = -1 + /// Track the last cursor Y position for smart invalidation (O(1) comparison) + private var lastCursorLineY: CGFloat = -.greatestFiniteMagnitude + /// Cached rect of the last cursor line for invalidation + private var lastCursorLineRect: NSRect? /// Margin to expand invalidation rect to ensure borders/effects are redrawn private let lineInvalidationMargin: CGFloat = 2 @@ -37,18 +39,25 @@ final class EditorTextView: NSTextView { "{": "}", ] - private let reverseBracketPairs: [Character: Character] = [ - ")": "(", - "]": "[", - "}": "{", - ] - private let quotePairs: [Character: Character] = [ "'": "'", "\"": "\"", "`": "`", ] + // UTF-16 bracket pair maps for O(1) bracket matching without Array(string) + private let bracketPairMap: [unichar: unichar] = [ + unichar(UnicodeScalar("(").value): unichar(UnicodeScalar(")").value), + unichar(UnicodeScalar("[").value): unichar(UnicodeScalar("]").value), + unichar(UnicodeScalar("{").value): unichar(UnicodeScalar("}").value), + ] + + private let reverseBracketPairMap: [unichar: unichar] = [ + unichar(UnicodeScalar(")").value): unichar(UnicodeScalar("(").value), + unichar(UnicodeScalar("]").value): unichar(UnicodeScalar("[").value), + unichar(UnicodeScalar("}").value): unichar(UnicodeScalar("{").value), + ] + // MARK: - Initialization override init(frame frameRect: NSRect, textContainer container: NSTextContainer?) { @@ -81,8 +90,12 @@ final class EditorTextView: NSTextView { @objc private func textDidChange(_ notification: Notification) { // Invalidate line cache when text changes lineCache = nil - // Reset last cursor line to avoid stale line numbers from previous document state - lastCursorLine = -1 + // NOTE: Do NOT reset lastCursorLineY here. Resetting it forces + // invalidateLineHighlightIfNeeded() to query the layout manager on + // every single keystroke, even when typing on the same line. For 40MB + // files this triggers expensive layout computation per keystroke. + // The selectionDidChange handler naturally detects line changes via + // Y-position comparison, so the highlight stays correct without reset. } deinit { @@ -95,77 +108,94 @@ final class EditorTextView: NSTextView { invalidateLineHighlightIfNeeded() } - /// Invalidate only the current and previous line regions for redraw + /// Invalidate only the current and previous line regions for redraw. + /// Uses O(1) layout manager glyph lookup + Y-position comparison instead of + /// iterating all lines before the cursor. + /// + /// Key optimization for large files: `textDidChange` does NOT reset + /// `lastCursorLineY`, so typing on the same line is a no-op here + /// (the Y comparison short-circuits). Layout manager queries for the + /// visible area are O(1) since layout is already cached. private func invalidateLineHighlightIfNeeded() { guard let layoutManager = layoutManager, - let textContainer = textContainer else { + layoutManager.numberOfGlyphs > 0 else { needsDisplay = true return } - let cursorPos = selectedRange().location + let charCount = (string as NSString).length + guard charCount > 0 else { + needsDisplay = true + return + } - // Calculate current line by iterating line-by-line with NSString's line APIs - // (more efficient than manual per-character scanning, but still linear in the - // number of lines before the cursor) - let currentLine: Int - if string.isEmpty { - currentLine = 0 + let cursorPos = selectedRange().location + let clampedPos = min(max(cursorPos, 0), charCount) + + // Get the Y position of the current cursor line via layout manager. + // For the visible area, these calls are O(1) — layout is already cached + // by the text view's display cycle. No ensureLayout needed. + let currentLineY: CGFloat + var currentRect: NSRect? + + // O(1) trailing newline check via NSString UTF-16 access + let nsText = string as NSString + if clampedPos >= charCount && nsText.character(at: charCount - 1) == 0x0A { + // Cursor on empty last line after trailing newline + let lastGlyph = layoutManager.numberOfGlyphs - 1 + guard lastGlyph >= 0 else { needsDisplay = true; return } + let lastRect = layoutManager.lineFragmentRect(forGlyphAt: lastGlyph, effectiveRange: nil) + currentLineY = lastRect.maxY + textContainerOrigin.y + // Compute currentRect so lastCursorLineRect is set — otherwise the + // old highlight can never be invalidated when the cursor moves away. + let emptyLineRect = NSRect( + x: textContainerOrigin.x, + y: lastRect.maxY + textContainerOrigin.y, + width: bounds.width - textContainerOrigin.x * 2, + height: lastRect.height + ) + currentRect = emptyLineRect } else { - let nsString = string as NSString - let length = nsString.length - - // Clamp cursor position to valid UTF-16 range - let clampedCursorPos = min(max(cursorPos, 0), length) - - var lineNumber = 0 - var index = 0 - - // Walk line by line until we reach or pass the cursor position - while index < clampedCursorPos { - var lineStart = 0 - var lineEnd = 0 - var contentsEnd = 0 - - nsString.getLineStart(&lineStart, - end: &lineEnd, - contentsEnd: &contentsEnd, - for: NSRange(location: index, length: 0)) - - // If we've reached the last line, stop - if lineEnd <= index { - break - } - - if lineEnd > clampedCursorPos { - break - } - - lineNumber += 1 - index = lineEnd + let safePos = min(clampedPos, max(charCount - 1, 0)) + let glyphIndex = layoutManager.glyphIndexForCharacter(at: safePos) + guard glyphIndex < layoutManager.numberOfGlyphs else { + needsDisplay = true + return } - - currentLine = lineNumber + let lineRect = layoutManager.lineFragmentRect(forGlyphAt: glyphIndex, effectiveRange: nil) + currentLineY = lineRect.origin.y + textContainerOrigin.y + var adjustedRect = lineRect + adjustedRect.origin.x = textContainerOrigin.x + adjustedRect.origin.y += textContainerOrigin.y + adjustedRect.size.width = bounds.width - textContainerOrigin.x * 2 + currentRect = adjustedRect } - // Skip if cursor is on the same line - if currentLine == lastCursorLine { + // Skip if cursor is on the same line (compare Y positions). + // This is the fast path for typing — since textDidChange does NOT + // reset lastCursorLineY, consecutive keystrokes on the same line + // hit this early return and skip all invalidation work. + if abs(currentLineY - lastCursorLineY) < 1.0 { return } - // Invalidate the previous line rect - if lastCursorLine >= 0 { - if let rect = lineRectForLine(lastCursorLine, layoutManager: layoutManager, textContainer: textContainer) { - setNeedsDisplay(rect.insetBy(dx: -lineInvalidationMargin, dy: -lineInvalidationMargin)) - } + // Invalidate the previous line rect so super.drawBackground clears + // the old highlight. If lastCursorLineRect is nil (rare — should be + // initialized by initializeCursorLineTracking), fall back to a full + // redraw to ensure the stale highlight is cleared. + if let prevRect = lastCursorLineRect { + setNeedsDisplay(prevRect.insetBy(dx: -lineInvalidationMargin, dy: -lineInvalidationMargin)) + } else { + needsDisplay = true } // Invalidate the current line rect - if let rect = lineRectForLine(currentLine, layoutManager: layoutManager, textContainer: textContainer) { + if let rect = currentRect { setNeedsDisplay(rect.insetBy(dx: -lineInvalidationMargin, dy: -lineInvalidationMargin)) } - lastCursorLine = currentLine + lastCursorLineY = currentLineY + lastCursorLineRect = currentRect } /// Simple cache for line lookups to avoid repeated O(n) scans for consecutive lines. @@ -239,8 +269,8 @@ final class EditorTextView: NSTextView { // If we reached the target line, charIndex is already set to its start // Otherwise it was clamped to the last valid position - layoutManager.ensureLayout(for: textContainer) - + // Do NOT call ensureLayout — with allowsNonContiguousLayout = true, + // glyphIndexForCharacter triggers local layout lazily as needed. let glyphIndex = layoutManager.glyphIndexForCharacter(at: charIndex) guard glyphIndex < layoutManager.numberOfGlyphs else { return nil } @@ -255,26 +285,77 @@ final class EditorTextView: NSTextView { return lineRect } + /// Initialize cursor line tracking state. + /// Must be called after the delegate is set up so that the first cursor + /// movement can properly invalidate the initial highlight position. + /// Without this, `lastCursorLineRect` stays nil and the stale highlight + /// drawn at the initial cursor position (end of text) is never cleared. + func initializeCursorLineTracking() { + guard let layoutManager = layoutManager, + layoutManager.numberOfGlyphs > 0 else { return } + + let charCount = (string as NSString).length + guard charCount > 0 else { return } + + let cursorPos = selectedRange().location + let clampedPos = min(max(cursorPos, 0), charCount) + + let nsText = string as NSString + if clampedPos >= charCount && nsText.character(at: charCount - 1) == 0x0A { + let lastGlyph = layoutManager.numberOfGlyphs - 1 + guard lastGlyph >= 0 else { return } + let lastRect = layoutManager.lineFragmentRect( + forGlyphAt: lastGlyph, effectiveRange: nil + ) + lastCursorLineY = lastRect.maxY + textContainerOrigin.y + lastCursorLineRect = NSRect( + x: textContainerOrigin.x, + y: lastRect.maxY + textContainerOrigin.y, + width: bounds.width - textContainerOrigin.x * 2, + height: lastRect.height + ) + } else { + let safePos = min(clampedPos, max(charCount - 1, 0)) + let glyphIndex = layoutManager.glyphIndexForCharacter(at: safePos) + guard glyphIndex < layoutManager.numberOfGlyphs else { return } + let lineRect = layoutManager.lineFragmentRect( + forGlyphAt: glyphIndex, effectiveRange: nil + ) + lastCursorLineY = lineRect.origin.y + textContainerOrigin.y + var adjustedRect = lineRect + adjustedRect.origin.x = textContainerOrigin.x + adjustedRect.origin.y += textContainerOrigin.y + adjustedRect.size.width = bounds.width - textContainerOrigin.x * 2 + lastCursorLineRect = adjustedRect + } + } + // MARK: - Drawing /// Draw background elements (current line highlight, bracket matching) override func drawBackground(in rect: NSRect) { super.drawBackground(in: rect) - // Draw visual features after background - drawCurrentLineHighlight() + // Draw visual features after background, clipped to the dirty rect. + // Only draw the highlight if it intersects the area being redrawn — + // otherwise we'd re-paint a stale highlight in a region that + // super.drawBackground didn't clear. + drawCurrentLineHighlight(in: rect) drawBracketHighlights() } - /// Draw highlight for the current line - private func drawCurrentLineHighlight() { - guard let layoutManager = layoutManager, - let textContainer = textContainer else { return } + /// Draw highlight for the current line, clipped to the given dirty rect. + /// If the highlight rect does not intersect `dirtyRect`, drawing is skipped + /// to prevent re-painting a stale highlight in a region that was not cleared. + private func drawCurrentLineHighlight(in dirtyRect: NSRect) { + guard let layoutManager = layoutManager else { return } let cursorPos = selectedRange().location + let nsString = string as NSString + let textLength = nsString.length // Handle empty document - if string.isEmpty { + if textLength == 0 { let origin = textContainerOrigin let lineRect = NSRect( x: origin.x, @@ -282,28 +363,31 @@ final class EditorTextView: NSTextView { width: bounds.width - origin.x * 2, height: 17 ) + guard lineRect.intersects(dirtyRect) else { return } SQLEditorTheme.currentLineHighlight.setFill() - NSBezierPath(roundedRect: lineRect, xRadius: SQLEditorTheme.highlightCornerRadius, yRadius: SQLEditorTheme.highlightCornerRadius).fill() + NSBezierPath( + roundedRect: lineRect, + xRadius: SQLEditorTheme.highlightCornerRadius, + yRadius: SQLEditorTheme.highlightCornerRadius + ).fill() return } - layoutManager.ensureLayout(for: textContainer) - + // Do NOT call ensureLayout — with allowsNonContiguousLayout = true, + // glyphIndexForCharacter / lineFragmentRect trigger local layout lazily. guard layoutManager.numberOfGlyphs > 0 else { return } var lineRect: NSRect // Handle cursor at end of document - if cursorPos >= string.count { - // Cursor is at or past the end - if string.hasSuffix("\n") { - // Trailing newline - cursor on new empty line - let lastGlyphIndex = layoutManager.numberOfGlyphs - 1 + if cursorPos >= textLength { + // O(1) check for trailing newline using NSString UTF-16 access + let hasTrailingNewline = nsString.character(at: textLength - 1) == 0x0A + let lastGlyphIndex = layoutManager.numberOfGlyphs - 1 + if hasTrailingNewline { let lastLineRect = layoutManager.lineFragmentRect(forGlyphAt: lastGlyphIndex, effectiveRange: nil) lineRect = NSRect(x: 0, y: lastLineRect.maxY, width: bounds.width, height: lastLineRect.height) } else { - // No trailing newline - cursor on same line as last character - let lastGlyphIndex = layoutManager.numberOfGlyphs - 1 lineRect = layoutManager.lineFragmentRect(forGlyphAt: lastGlyphIndex, effectiveRange: nil) } } else { @@ -319,52 +403,78 @@ final class EditorTextView: NSTextView { lineRect.origin.y += origin.y lineRect.size.width = bounds.width - origin.x * 2 + // Only draw the highlight if it intersects the dirty rect. + // When the cursor moves, only the old and new line rects are + // invalidated. If drawBackground is called for a dirty rect that + // does NOT include the current cursor line (e.g., the old line + // being cleared), we must skip drawing here — otherwise we'd + // re-paint the highlight at the cursor position in a region whose + // old content was not cleared by super.drawBackground. + guard lineRect.intersects(dirtyRect) else { return } + SQLEditorTheme.currentLineHighlight.setFill() - NSBezierPath(roundedRect: lineRect, xRadius: SQLEditorTheme.highlightCornerRadius, yRadius: SQLEditorTheme.highlightCornerRadius).fill() + NSBezierPath( + roundedRect: lineRect, + xRadius: SQLEditorTheme.highlightCornerRadius, + yRadius: SQLEditorTheme.highlightCornerRadius + ).fill() } /// Draw highlights for matching brackets private func drawBracketHighlights() { guard let layoutManager = layoutManager, - let textContainer = textContainer, + textContainer != nil, !string.isEmpty, layoutManager.numberOfGlyphs > 0 else { return } + let nsString = string as NSString + let length = nsString.length + + // Skip bracket highlighting for very large documents — the layout manager + // queries and bracket search add overhead that causes lag during editing. + if length > 1_000_000 { return } + let cursorPos = selectedRange().location - let chars = Array(string) - // Find bracket at or before cursor + // Find bracket at or before cursor using UTF-16 access (O(1) per char) var bracketPos: Int? - var bracket: Character? + var bracketUnichar: unichar? - if cursorPos < chars.count { - let char = chars[cursorPos] - if bracketPairs[char] != nil || reverseBracketPairs[char] != nil { + if cursorPos < length { + let ch = nsString.character(at: cursorPos) + if bracketPairMap[ch] != nil || reverseBracketPairMap[ch] != nil { bracketPos = cursorPos - bracket = char + bracketUnichar = ch } } - if bracket == nil && cursorPos > 0 && cursorPos - 1 < chars.count { - let char = chars[cursorPos - 1] - if bracketPairs[char] != nil || reverseBracketPairs[char] != nil { + if bracketUnichar == nil && cursorPos > 0 && cursorPos - 1 < length { + let ch = nsString.character(at: cursorPos - 1) + if bracketPairMap[ch] != nil || reverseBracketPairMap[ch] != nil { bracketPos = cursorPos - 1 - bracket = char + bracketUnichar = ch } } guard let foundPos = bracketPos, - let foundBracket = bracket, - let matchPos = findMatchingBracket(at: foundPos, bracket: foundBracket, in: chars) else { return } + let foundBracket = bracketUnichar, + let matchPos = findMatchingBracketUTF16( + at: foundPos, bracket: foundBracket, in: nsString + ) else { return } - layoutManager.ensureLayout(for: textContainer) + // Do NOT call ensureLayout — with allowsNonContiguousLayout = true, + // the layout manager handles lazy layout for glyph queries. // Draw highlight for both brackets SQLEditorTheme.bracketMatchHighlight.setFill() for pos in [foundPos, matchPos] { if let rect = rectForCharacter(at: pos) { - NSBezierPath(roundedRect: rect, xRadius: SQLEditorTheme.highlightCornerRadius, yRadius: SQLEditorTheme.highlightCornerRadius).fill() + NSBezierPath( + roundedRect: rect, + xRadius: SQLEditorTheme.highlightCornerRadius, + yRadius: SQLEditorTheme.highlightCornerRadius + ).fill() } } } @@ -460,32 +570,39 @@ final class EditorTextView: NSTextView { // MARK: - Auto-Indent - /// Override newline to auto-indent based on previous line + /// Override newline to auto-indent based on previous line. + /// Uses NSString range scanning to avoid O(n) String allocation for large files. override func insertNewline(_ sender: Any?) { guard SQLEditorTheme.autoIndent else { super.insertNewline(sender) return } - let text = self.string - let cursorPos = selectedRange().location + let nsText = string as NSString + let cursorPos = min(selectedRange().location, nsText.length) + + // Find the last newline before cursor using NSString backwards search (O(line length)) + let searchRange = NSRange(location: 0, length: cursorPos) + let newlineRange = nsText.range(of: "\n", options: .backwards, range: searchRange) - // Find start of current line - let textBeforeCursor = String(text.prefix(cursorPos)) - guard let lastNewline = textBeforeCursor.lastIndex(of: "\n") else { + guard newlineRange.location != NSNotFound else { // First line, no indent to copy super.insertNewline(sender) return } - let lineStart = textBeforeCursor.index(after: lastNewline) - let currentLine = String(textBeforeCursor[lineStart...]) - - // Extract leading whitespace + // Extract leading whitespace from the line after the newline + let lineStart = newlineRange.location + 1 var indent = "" - for char in currentLine { - if char == " " || char == "\t" { - indent.append(char) + var pos = lineStart + while pos < cursorPos { + let ch = nsText.character(at: pos) + if ch == 0x20 { // space + indent.append(" ") + pos += 1 + } else if ch == 0x09 { // tab + indent.append("\t") + pos += 1 } else { break } @@ -516,32 +633,27 @@ final class EditorTextView: NSTextView { private func shouldSkipClosingQuote(_ quote: Character) -> Bool { let pos = selectedRange().location - let utf16View = string.utf16 - guard pos < utf16View.count else { return false } - let index = utf16View.index(utf16View.startIndex, offsetBy: pos) - guard let scalar = UnicodeScalar(utf16View[index]) else { return false } + let nsString = string as NSString + guard pos < nsString.length else { return false } + guard let scalar = UnicodeScalar(nsString.character(at: pos)) else { return false } return Character(scalar) == quote } private func shouldSkipClosingBracket(_ bracket: Character) -> Bool { let pos = selectedRange().location - let utf16View = string.utf16 - guard pos < utf16View.count else { return false } - let index = utf16View.index(utf16View.startIndex, offsetBy: pos) - guard let scalar = UnicodeScalar(utf16View[index]) else { return false } + let nsString = string as NSString + guard pos < nsString.length else { return false } + guard let scalar = UnicodeScalar(nsString.character(at: pos)) else { return false } return Character(scalar) == bracket } private func shouldDeletePair() -> Bool { let pos = selectedRange().location - let utf16View = string.utf16 - guard pos > 0, pos < utf16View.count else { return false } - - let prevIndex = utf16View.index(utf16View.startIndex, offsetBy: pos - 1) - let nextIndex = utf16View.index(utf16View.startIndex, offsetBy: pos) + let nsString = string as NSString + guard pos > 0, pos < nsString.length else { return false } - guard let prevScalar = UnicodeScalar(utf16View[prevIndex]), - let nextScalar = UnicodeScalar(utf16View[nextIndex]) else { return false } + guard let prevScalar = UnicodeScalar(nsString.character(at: pos - 1)), + let nextScalar = UnicodeScalar(nsString.character(at: pos)) else { return false } let prevChar = Character(prevScalar) let nextChar = Character(nextScalar) @@ -568,30 +680,38 @@ final class EditorTextView: NSTextView { // MARK: - Bracket Matching - private func findMatchingBracket(at position: Int, bracket: Character, in chars: [Character]) -> Int? { - let isOpening = bracketPairs[bracket] != nil - let matchingBracket: Character + /// Maximum distance to search for matching bracket (prevents scanning 170k chars) + private static let maxBracketSearchDistance = 5_000 + + /// UTF-16 bracket matching using NSString — avoids O(n) Array(string) allocation. + /// Caps search at ±5000 characters to stay responsive on large files. + private func findMatchingBracketUTF16(at position: Int, bracket: unichar, in nsString: NSString) -> Int? { + let isOpening = bracketPairMap[bracket] != nil + let matchingBracket: unichar let direction: Int if isOpening { - guard let match = bracketPairs[bracket] else { return nil } + guard let match = bracketPairMap[bracket] else { return nil } matchingBracket = match direction = 1 } else { - guard let match = reverseBracketPairs[bracket] else { return nil } + guard let match = reverseBracketPairMap[bracket] else { return nil } matchingBracket = match direction = -1 } + let length = nsString.length + let limit = Self.maxBracketSearchDistance var depth = 1 var pos = position + direction + var searched = 0 - while pos >= 0 && pos < chars.count { - let char = chars[pos] + while pos >= 0 && pos < length && searched < limit { + let ch = nsString.character(at: pos) - if char == bracket { + if ch == bracket { depth += 1 - } else if char == matchingBracket { + } else if ch == matchingBracket { depth -= 1 if depth == 0 { return pos @@ -599,15 +719,16 @@ final class EditorTextView: NSTextView { } pos += direction + searched += 1 } - return nil // No matching bracket found + return nil } private func rectForCharacter(at index: Int) -> NSRect? { guard let layoutManager = layoutManager, let container = textContainer, - index < string.count, + index < (string as NSString).length, layoutManager.numberOfGlyphs > 0 else { return nil } let glyphIndex = layoutManager.glyphIndexForCharacter(at: index) diff --git a/TablePro/Views/Editor/LineNumberView.swift b/TablePro/Views/Editor/LineNumberView.swift index 36de72abf..f7a89a42d 100644 --- a/TablePro/Views/Editor/LineNumberView.swift +++ b/TablePro/Views/Editor/LineNumberView.swift @@ -19,12 +19,12 @@ final class LineNumberView: NSView { /// Cached line start indices (character positions) private var lineStartIndices: [Int] = [0] - /// Last known text length (to detect changes) - private var lastTextLength: Int = 0 - /// Current width of the view private var currentWidth: CGFloat = SQLEditorTheme.lineNumberRulerMinThickness + /// Debounce work item for line cache rebuild (avoids rebuilding 170k line cache per keystroke) + private var lineCacheDebounceItem: DispatchWorkItem? + // MARK: - Initialization init(textView: NSTextView, scrollView: NSScrollView) { @@ -53,9 +53,21 @@ final class LineNumberView: NSView { object: clipView ) - // Initial cache build - updateLineCache(for: textView.string) - updateWidth() + // Initial cache build — defer for large documents to avoid blocking main thread + let nsText = textView.string as NSString + let textLength = nsText.length + if textLength > 100_000 { + DispatchQueue.main.async { [weak self] in + guard let self, let tv = self.textView else { return } + let currentNSText = tv.string as NSString + self.rebuildLineCache(for: currentNSText) + self.updateWidth() + self.needsDisplay = true + } + } else { + rebuildLineCache(for: nsText) + updateWidth() + } } required init?(coder: NSCoder) { @@ -81,9 +93,30 @@ final class LineNumberView: NSView { @objc private func textDidChange(_ notification: Notification) { guard let textView = textView else { return } - updateLineCache(for: textView.string) - updateWidth() - needsDisplay = true + let nsText = textView.string as NSString + let textLength = nsText.length + + // For large documents, debounce the full line cache rebuild. + // draw() enumerates line fragments from the layout manager directly + // (not from lineStartIndices), so stale cache only affects the + // first-visible-line NUMBER — off by ±1 during the debounce window. + if textLength > 10_000 { + lineCacheDebounceItem?.cancel() + let item = DispatchWorkItem { [weak self] in + guard let self, let tv = self.textView else { return } + let currentNSText = tv.string as NSString + self.rebuildLineCache(for: currentNSText) + self.updateWidth() + self.needsDisplay = true + } + lineCacheDebounceItem = item + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2, execute: item) + needsDisplay = true + } else { + rebuildLineCache(for: nsText) + updateWidth() + needsDisplay = true + } } @objc private func boundsDidChange(_ notification: Notification) { @@ -93,35 +126,21 @@ final class LineNumberView: NSView { // MARK: - Line Cache Management - /// Update cached line start indices - private func updateLineCache(for text: String) { - // If text is empty, reset to single line - guard !text.isEmpty else { - lineStartIndices = [0] - lastTextLength = 0 - return - } - - // If length changed significantly, rebuild cache - let textLength = text.count - if abs(textLength - lastTextLength) > 100 || lineStartIndices.isEmpty { - rebuildLineCache(for: text) - } else { - // For simplicity, rebuild (still fast for typical edits) - rebuildLineCache(for: text) - } - - lastTextLength = textLength - } - - /// Rebuild line cache from scratch - private func rebuildLineCache(for text: String) { + /// Rebuild line cache from scratch using fast NSString scanning. + /// Accepts NSString directly to avoid String<->NSString bridging overhead. + private func rebuildLineCache(for nsString: NSString) { lineStartIndices = [0] - for (index, char) in text.enumerated() { - if char == "\n" { - lineStartIndices.append(index + 1) - } + let length = nsString.length + var searchStart = 0 + while searchStart < length { + let range = nsString.range( + of: "\n", + range: NSRange(location: searchStart, length: length - searchStart) + ) + if range.location == NSNotFound { break } + lineStartIndices.append(range.location + 1) + searchStart = range.location + 1 } } @@ -157,10 +176,11 @@ final class LineNumberView: NSView { let textContainer = textView.textContainer, let scrollView = scrollView else { return } - let text = textView.string + let nsText = textView.string as NSString + let textLength = nsText.length // Handle empty document - guard !text.isEmpty else { + guard textLength > 0 else { drawLineNumber(1, at: textView.textContainerOrigin.y) return } @@ -169,55 +189,98 @@ final class LineNumberView: NSView { let visibleRect = scrollView.contentView.bounds let textContainerOrigin = textView.textContainerOrigin - // Ensure layout - layoutManager.ensureLayout(for: textContainer) - - guard layoutManager.numberOfGlyphs > 0 else { return } - - // Get visible glyph range - let visibleGlyphRange = layoutManager.glyphRange(forBoundingRect: visibleRect, in: textContainer) - guard visibleGlyphRange.location != NSNotFound else { return } + // Get visible glyph range (triggers lazy layout for visible area only, + // avoids forcing full-document layout which freezes on large files) + let visibleGlyphRange = layoutManager.glyphRange( + forBoundingRect: visibleRect, in: textContainer + ) + guard visibleGlyphRange.location != NSNotFound, + visibleGlyphRange.length > 0 else { return } // Get character range for visible glyphs - let visibleCharRange = layoutManager.characterRange(forGlyphRange: visibleGlyphRange, actualGlyphRange: nil) - - // Find first visible line using binary search on cached indices - let firstVisibleLine = lineStartIndices.lastIndex { $0 <= visibleCharRange.location } ?? 0 - - // Draw line numbers for visible lines - var lineNumber = firstVisibleLine + 1 - var currentIndex = firstVisibleLine - var lastDrawnY: CGFloat = -1_000 // Track last Y position to avoid duplicates - - while currentIndex < lineStartIndices.count { - let lineStart = lineStartIndices[currentIndex] + let visibleCharRange = layoutManager.characterRange( + forGlyphRange: visibleGlyphRange, actualGlyphRange: nil + ) - // Stop if we've gone past visible range - if lineStart >= NSMaxRange(visibleCharRange) { - break - } + // Get the first visible line NUMBER from the (possibly stale) cache. + // During the 200ms debounce window this may be off by ±1 which is + // imperceptible. The cache is never used for character POSITIONS — + // we enumerate line fragments from the layout manager instead. + let firstVisibleLineIdx = binarySearchLastIndex( + in: lineStartIndices, atOrBefore: visibleCharRange.location + ) + var lineNumber = firstVisibleLineIdx + 1 - // Get glyph index for this line - let glyphIndex = layoutManager.glyphIndexForCharacter(at: lineStart) - guard glyphIndex < layoutManager.numberOfGlyphs else { break } + // Enumerate line fragments in the visible glyph range. + // This uses the layout manager's current state (always accurate) + // and only touches the already-laid-out visible region — O(visible lines). + var glyphIdx = visibleGlyphRange.location + var lastDrawnY: CGFloat = -1_000 - // Get line fragment rect (this gives us the first line fragment for wrapped lines) + while glyphIdx < NSMaxRange(visibleGlyphRange) { var effectiveRange = NSRange() - let lineRect = layoutManager.lineFragmentRect(forGlyphAt: glyphIndex, effectiveRange: &effectiveRange) + let lineRect = layoutManager.lineFragmentRect( + forGlyphAt: glyphIdx, effectiveRange: &effectiveRange + ) + + // Check if this fragment starts a real line (not a wrapped continuation). + // A real line start is at character 0 or right after a newline. + let charIdx = layoutManager.characterIndexForGlyph(at: glyphIdx) + let isRealLineStart = charIdx == 0 + || nsText.character(at: charIdx - 1) == 0x0A // '\n' + + if isRealLineStart { + let yPos = floor( + lineRect.origin.y + textContainerOrigin.y - visibleRect.origin.y + ) + if abs(yPos - lastDrawnY) > 1.0 { + drawLineNumber(lineNumber, at: yPos) + lastDrawnY = yPos + } + lineNumber += 1 + } - // Calculate Y position in line number view coordinates - // Pixel-align for crisp rendering - let yPos = floor(lineRect.origin.y + textContainerOrigin.y - visibleRect.origin.y) + // Advance past this fragment. Safety: always advance by at least 1 + // to prevent infinite loop if effectiveRange is zero-length. + let nextGlyph = NSMaxRange(effectiveRange) + glyphIdx = nextGlyph > glyphIdx ? nextGlyph : glyphIdx + 1 + } - // Only draw if this is a new Y position (avoid drawing for wrapped line fragments) + // Handle trailing empty line: if text ends with \n, the last line + // has no glyphs so it wasn't covered by the fragment enumeration. + if nsText.character(at: textLength - 1) == 0x0A { + let lastGlyph = layoutManager.numberOfGlyphs + guard lastGlyph > 0 else { return } + let lastRect = layoutManager.lineFragmentRect( + forGlyphAt: lastGlyph - 1, effectiveRange: nil + ) + let yPos = floor( + lastRect.maxY + textContainerOrigin.y - visibleRect.origin.y + ) if abs(yPos - lastDrawnY) > 1.0 { drawLineNumber(lineNumber, at: yPos) - lastDrawnY = yPos } + } + } - lineNumber += 1 - currentIndex += 1 + /// Binary search for the last index in a sorted array whose value is ≤ target. + /// Returns 0 if no element satisfies the condition. + private func binarySearchLastIndex(in array: [Int], atOrBefore target: Int) -> Int { + var low = 0 + var high = array.count - 1 + var result = 0 + + while low <= high { + let mid = low + (high - low) / 2 + if array[mid] <= target { + result = mid + low = mid + 1 + } else { + high = mid - 1 + } } + + return result } /// Draw a single line number at the specified Y position diff --git a/TablePro/Views/Editor/NativeTabBar.swift b/TablePro/Views/Editor/NativeTabBar.swift index 06771b950..d98baed42 100644 --- a/TablePro/Views/Editor/NativeTabBar.swift +++ b/TablePro/Views/Editor/NativeTabBar.swift @@ -11,6 +11,15 @@ import SwiftUI struct NativeTabBar: NSViewRepresentable { @ObservedObject var tabManager: QueryTabManager + func makeCoordinator() -> Coordinator { + Coordinator() + } + + final class Coordinator { + var cachedSnapshots: [TabSnapshot] = [] + var cachedSelectedId: UUID? + } + func makeNSView(context: Context) -> NativeTabBarView { let view = NativeTabBarView() @@ -67,6 +76,16 @@ struct NativeTabBar: NSViewRepresentable { tabType: tab.tabType ) } - nsView.updateTabs(snapshots, selectedId: tabManager.selectedTabId) + let selectedId = tabManager.selectedTabId + + // Skip update if tab metadata hasn't changed (query text edits don't affect snapshots) + let coordinator = context.coordinator + if snapshots == coordinator.cachedSnapshots && selectedId == coordinator.cachedSelectedId { + return + } + coordinator.cachedSnapshots = snapshots + coordinator.cachedSelectedId = selectedId + + nsView.updateTabs(snapshots, selectedId: selectedId) } } diff --git a/TablePro/Views/Editor/NativeTabBarView.swift b/TablePro/Views/Editor/NativeTabBarView.swift index 97a736782..d1029ab3d 100644 --- a/TablePro/Views/Editor/NativeTabBarView.swift +++ b/TablePro/Views/Editor/NativeTabBarView.swift @@ -11,7 +11,7 @@ import AppKit private let tabPasteboardType = NSPasteboard.PasteboardType("com.TablePro.tab") /// Lightweight snapshot of a QueryTab for passing to AppKit layer -struct TabSnapshot { +struct TabSnapshot: Equatable { let id: UUID let title: String let isPinned: Bool diff --git a/TablePro/Views/Editor/SQLEditorView.swift b/TablePro/Views/Editor/SQLEditorView.swift index f837c72e2..8790723ac 100644 --- a/TablePro/Views/Editor/SQLEditorView.swift +++ b/TablePro/Views/Editor/SQLEditorView.swift @@ -33,6 +33,9 @@ struct SQLEditorView: NSViewRepresentable { // Create text storage, layout manager, and text container let textStorage = NSTextStorage() let layoutManager = NSLayoutManager() + // Allow non-contiguous layout: lets the layout manager skip laying out offscreen + // regions. This is the single most important setting for large-document performance. + layoutManager.allowsNonContiguousLayout = true textStorage.addLayoutManager(layoutManager) let textContainer = NSTextContainer(size: NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude)) @@ -69,21 +72,29 @@ struct SQLEditorView: NSViewRepresentable { textView.insertionPointColor = SQLEditorTheme.insertionPoint textView.textContainerInset = SQLEditorTheme.textContainerInset - // Disable automatic text substitutions for SQL syntax integrity + // Disable all automatic text features for SQL syntax integrity and performance textView.isAutomaticQuoteSubstitutionEnabled = false textView.isAutomaticDashSubstitutionEnabled = false textView.isAutomaticSpellingCorrectionEnabled = false textView.isAutomaticTextReplacementEnabled = false + textView.isContinuousSpellCheckingEnabled = false + textView.isGrammarCheckingEnabled = false + textView.isAutomaticTextCompletionEnabled = false + textView.isAutomaticLinkDetectionEnabled = false + textView.usesFontPanel = false + textView.usesFindBar = true // Set initial text textView.string = text + // MUST set documentView BEFORE coordinator setup so that + // textView.enclosingScrollView is non-nil when SyntaxHighlighter + // attaches its scroll observer for viewport-based highlighting. + scrollView.documentView = textView + // Set up coordinator (textStorage is now guaranteed to exist) context.coordinator.setup(textView: textView, textStorage: textStorage) - // MUST set documentView BEFORE creating line number view - scrollView.documentView = textView - // Create custom line number view (positioned left of scroll view) let lineNumberView = LineNumberView(textView: textView, scrollView: scrollView) diff --git a/TablePro/Views/Editor/SyntaxHighlighter.swift b/TablePro/Views/Editor/SyntaxHighlighter.swift index 37172cfdc..4106d0358 100644 --- a/TablePro/Views/Editor/SyntaxHighlighter.swift +++ b/TablePro/Views/Editor/SyntaxHighlighter.swift @@ -2,7 +2,9 @@ // SyntaxHighlighter.swift // TablePro // -// Incremental syntax highlighter for SQL using NSTextStorageDelegate +// Incremental syntax highlighter for SQL using NSTextStorageDelegate. +// Uses viewport-only highlighting for large documents — only the visible +// range plus a buffer zone is highlighted, with lazy expansion on scroll. // import AppKit @@ -12,6 +14,32 @@ final class SyntaxHighlighter: NSObject, NSTextStorageDelegate { // MARK: - Properties private weak var textStorage: NSTextStorage? + private weak var scrollView: NSScrollView? + private weak var textView: NSTextView? + + /// Edits larger than this trigger viewport-only highlighting instead of chunked full-doc + private static let viewportThreshold = 50_000 + + /// Buffer zone (in characters) above and below the viewport to pre-highlight + private static let viewportBuffer = 10_000 + + /// Maximum characters to highlight in a single highlightRange call. + /// SQL dumps often have mega-lines (millions of chars); running 7 regex + /// patterns on a 10MB line freezes the main thread for seconds. + private static let maxHighlightRangeSize = 10_000 + + /// Tracks which ranges have already been highlighted (avoids re-work on scroll) + private var highlightedRanges = IndexSet() + + /// Debounce timer for scroll-based highlighting + private var scrollDebounceItem: DispatchWorkItem? + + /// Whether the document is large enough to use viewport-only mode + private var isLargeDocument: Bool = false + + /// Reentrancy guard: prevents didProcessEditing from re-entering + /// when highlightRange's endEditing triggers another delegate callback + private var isProcessingEdit: Bool = false /// SQL keywords for highlighting (synced with SQLKeywords for consistency) private static let keywords: Set = [ @@ -109,10 +137,28 @@ final class SyntaxHighlighter: NSObject, NSTextStorageDelegate { textStorage.delegate = self } + /// Attach scroll view for viewport-based highlighting on scroll + func attachScrollView(_ scrollView: NSScrollView, textView: NSTextView) { + self.scrollView = scrollView + self.textView = textView + + let clipView = scrollView.contentView + clipView.postsBoundsChangedNotifications = true + NotificationCenter.default.addObserver( + self, + selector: #selector(scrollViewDidScroll), + name: NSView.boundsDidChangeNotification, + object: clipView + ) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + // MARK: - NSTextStorageDelegate /// Called after text storage processes an edit - /// This is THE RIGHT PLACE to apply syntax highlighting func textStorage( _ textStorage: NSTextStorage, didProcessEditing editedMask: NSTextStorageEditActions, @@ -122,48 +168,221 @@ final class SyntaxHighlighter: NSObject, NSTextStorageDelegate { // Only process character changes, not attribute-only changes guard editedMask.contains(.editedCharacters) else { return } - // Expand range to full line(s) - let text = textStorage.string - guard !text.isEmpty else { return } + // Prevent reentrancy: highlightRange calls endEditing which can + // fire didProcessEditing again, creating a tight CPU-burning loop. + guard !isProcessingEdit else { return } + isProcessingEdit = true + defer { isProcessingEdit = false } - let expandedRange = expandToLineRange(editedRange, in: text) + let length = textStorage.length + guard length > 0 else { return } + + isLargeDocument = length > Self.viewportThreshold + + if isLargeDocument { + // Large edit (file load, paste) — invalidate highlighted ranges that shifted + if delta != 0 { + shiftHighlightedRanges(editedLocation: editedRange.location, delta: delta) + } - // Apply highlighting to the expanded range only - highlightRange(expandedRange, in: textStorage) + // If the edit covers most of the document (file load, large paste), + // skip immediate highlighting — let viewport highlighting handle it. + // Running 7 regex patterns over 40MB freezes the app for seconds. + if editedRange.length > Self.viewportThreshold { + scheduleViewportHighlighting() + } else { + // Normal edit — highlight just the edited line range. + // Cap the range to prevent running regex on mega-lines (SQL dumps + // can have single lines with millions of characters). + let text = textStorage.string + var expandedRange = expandToLineRange(editedRange, in: text) + if expandedRange.length > Self.maxHighlightRangeSize { + // Center the capped range around the edit point + let editCenter = editedRange.location + editedRange.length / 2 + let halfCap = Self.maxHighlightRangeSize / 2 + let capStart = max(expandedRange.location, editCenter - halfCap) + let capEnd = min(NSMaxRange(expandedRange), capStart + Self.maxHighlightRangeSize) + expandedRange = NSRange(location: capStart, length: capEnd - capStart) + } + highlightRange(expandedRange, in: textStorage) + highlightedRanges.insert(integersIn: expandedRange.location.. 0 else { return } - highlightRange(fullRange, in: textStorage) + let length = textStorage.length + guard length > 0 else { return } + + isLargeDocument = length > Self.viewportThreshold + highlightedRanges = IndexSet() + + if isLargeDocument { + // Only highlight the viewport — rest is done lazily on scroll + highlightViewport() + } else { + highlightRange(NSRange(location: 0, length: length), in: textStorage) + highlightedRanges.insert(integersIn: 0.. 0 else { return } + + // Get the visible character range from the layout manager + let visibleRect = scrollView.contentView.bounds + let glyphRange = layoutManager.glyphRange(forBoundingRect: visibleRect, in: textContainer) + guard glyphRange.location != NSNotFound, glyphRange.length > 0 else { return } + + let charRange = layoutManager.characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil) + + // Expand by buffer zone + let bufferStart = max(0, charRange.location - Self.viewportBuffer) + let bufferEnd = min(length, NSMaxRange(charRange) + Self.viewportBuffer) + let targetRange = NSRange(location: bufferStart, length: bufferEnd - bufferStart) + + // Expand to line boundaries + let text = textStorage.string + let lineAligned = expandToLineRange(targetRange, in: text) + + // Find sub-ranges that haven't been highlighted yet + let targetSet = IndexSet(integersIn: lineAligned.location.. 0, NSMaxRange(aligned) <= length else { continue } + + // Split large ranges into chunks to avoid running regex on mega-lines + if aligned.length > Self.maxHighlightRangeSize { + var offset = aligned.location + while offset < NSMaxRange(aligned) { + let chunkLength = min(Self.maxHighlightRangeSize, NSMaxRange(aligned) - offset) + let chunk = NSRange(location: offset, length: chunkLength) + highlightRange(chunk, in: textStorage) + offset += chunkLength + } + } else { + highlightRange(aligned, in: textStorage) + } + } + + // Mark entire target as highlighted + highlightedRanges.formUnion(targetSet) + } + + /// Shift tracked ranges when text is edited (insert/delete) + private func shiftHighlightedRanges(editedLocation: Int, delta: Int) { + if delta > 0 { + // Insertion: shift ranges after the edit point forward + var shifted = IndexSet() + for range in highlightedRanges.rangeView { + if range.lowerBound >= editedLocation { + shifted.insert(integersIn: (range.lowerBound + delta)..<(range.upperBound + delta)) + } else if range.upperBound > editedLocation { + // Range straddles the edit: keep the part before, shift the part after + shifted.insert(integersIn: range.lowerBound..= deletedEnd { + shifted.insert(integersIn: (range.lowerBound + delta)..<(range.upperBound + delta)) + } else if range.upperBound <= editedLocation { + shifted.insert(integersIn: range) + } else { + // Range overlaps deletion — keep only the parts outside + if range.lowerBound < editedLocation { + shifted.insert(integersIn: range.lowerBound.. deletedEnd { + shifted.insert(integersIn: editedLocation..<(range.upperBound + delta)) + } + } + } + highlightedRanges = shifted + } } // MARK: - Private Helpers /// Expand edited range to include full lines private func expandToLineRange(_ range: NSRange, in text: String) -> NSRange { - guard !text.isEmpty, range.location < text.count else { - return NSRange(location: 0, length: text.count) + let nsString = text as NSString + let length = nsString.length + guard length > 0, range.location < length else { + return NSRange(location: 0, length: length) } - let nsString = text as NSString - let lineRange = nsString.lineRange(for: range) - return lineRange + let clampedRange = NSRange( + location: min(range.location, length), + length: min(range.length, length - min(range.location, length)) + ) + return nsString.lineRange(for: clampedRange) } /// Apply syntax highlighting to a specific range private func highlightRange(_ range: NSRange, in textStorage: NSTextStorage) { - guard range.length > 0, range.location + range.length <= textStorage.length else { + guard range.length > 0, NSMaxRange(range) <= textStorage.length else { return } - let text = textStorage.string - let nsText = text as NSString + let nsText = textStorage.string as NSString let substring = nsText.substring(with: range) + let substringLength = (substring as NSString).length // Begin editing (batch attribute changes) textStorage.beginEditing() @@ -180,73 +399,108 @@ final class SyntaxHighlighter: NSObject, NSTextStorageDelegate { // Find all strings for regex in Self.stringRegexes { - regex.enumerateMatches(in: substring, range: NSRange(location: 0, length: substring.count)) { match, _, _ in + regex.enumerateMatches( + in: substring, range: NSRange(location: 0, length: substringLength) + ) { match, _, _ in if let matchRange = match?.range { - let absoluteRange = NSRange(location: range.location + matchRange.location, length: matchRange.length) + let absoluteRange = NSRange( + location: range.location + matchRange.location, + length: matchRange.length + ) stringRanges.append(absoluteRange) - textStorage.addAttribute(.foregroundColor, value: SQLEditorTheme.string, range: absoluteRange) + textStorage.addAttribute( + .foregroundColor, value: SQLEditorTheme.string, range: absoluteRange + ) } } } // Find all comments - Self.singleLineCommentRegex?.enumerateMatches(in: substring, range: NSRange(location: 0, length: substring.count)) { match, _, _ in + Self.singleLineCommentRegex?.enumerateMatches( + in: substring, range: NSRange(location: 0, length: substringLength) + ) { match, _, _ in if let matchRange = match?.range { - let absoluteRange = NSRange(location: range.location + matchRange.location, length: matchRange.length) + let absoluteRange = NSRange( + location: range.location + matchRange.location, length: matchRange.length + ) commentRanges.append(absoluteRange) - textStorage.addAttribute(.foregroundColor, value: SQLEditorTheme.comment, range: absoluteRange) + textStorage.addAttribute( + .foregroundColor, value: SQLEditorTheme.comment, range: absoluteRange + ) } } - Self.multiLineCommentRegex?.enumerateMatches(in: substring, range: NSRange(location: 0, length: substring.count)) { match, _, _ in + Self.multiLineCommentRegex?.enumerateMatches( + in: substring, range: NSRange(location: 0, length: substringLength) + ) { match, _, _ in if let matchRange = match?.range { - let absoluteRange = NSRange(location: range.location + matchRange.location, length: matchRange.length) + let absoluteRange = NSRange( + location: range.location + matchRange.location, length: matchRange.length + ) commentRanges.append(absoluteRange) - textStorage.addAttribute(.foregroundColor, value: SQLEditorTheme.comment, range: absoluteRange) + textStorage.addAttribute( + .foregroundColor, value: SQLEditorTheme.comment, range: absoluteRange + ) } } - // Helper to check if a range overlaps with strings or comments + // Build IndexSet for O(log n) overlap checks + var stringOrCommentIndices = IndexSet() + for r in stringRanges { + stringOrCommentIndices.insert(integersIn: r.location..<(r.location + r.length)) + } + for r in commentRanges { + stringOrCommentIndices.insert(integersIn: r.location..<(r.location + r.length)) + } + let isInsideStringOrComment: (NSRange) -> Bool = { checkRange in - for stringRange in stringRanges { - if NSIntersectionRange(checkRange, stringRange).length > 0 { - return true - } - } - for commentRange in commentRanges { - if NSIntersectionRange(checkRange, commentRange).length > 0 { - return true - } - } - return false + stringOrCommentIndices.contains(checkRange.location) } // Highlight keywords (only outside strings/comments) - Self.keywordRegex?.enumerateMatches(in: substring, range: NSRange(location: 0, length: substring.count)) { match, _, _ in + Self.keywordRegex?.enumerateMatches( + in: substring, range: NSRange(location: 0, length: substringLength) + ) { match, _, _ in if let matchRange = match?.range { - let absoluteRange = NSRange(location: range.location + matchRange.location, length: matchRange.length) + let absoluteRange = NSRange( + location: range.location + matchRange.location, length: matchRange.length + ) if !isInsideStringOrComment(absoluteRange) { - textStorage.addAttribute(.foregroundColor, value: SQLEditorTheme.keyword, range: absoluteRange) + textStorage.addAttribute( + .foregroundColor, value: SQLEditorTheme.keyword, range: absoluteRange + ) } } } // Highlight numbers (only outside strings/comments) - Self.numberRegex?.enumerateMatches(in: substring, range: NSRange(location: 0, length: substring.count)) { match, _, _ in + Self.numberRegex?.enumerateMatches( + in: substring, range: NSRange(location: 0, length: substringLength) + ) { match, _, _ in if let matchRange = match?.range { - let absoluteRange = NSRange(location: range.location + matchRange.location, length: matchRange.length) + let absoluteRange = NSRange( + location: range.location + matchRange.location, length: matchRange.length + ) if !isInsideStringOrComment(absoluteRange) { - textStorage.addAttribute(.foregroundColor, value: SQLEditorTheme.number, range: absoluteRange) + textStorage.addAttribute( + .foregroundColor, value: SQLEditorTheme.number, range: absoluteRange + ) } } } // Highlight NULL, TRUE, FALSE (only outside strings/comments) - Self.nullBoolRegex?.enumerateMatches(in: substring, range: NSRange(location: 0, length: substring.count)) { match, _, _ in + Self.nullBoolRegex?.enumerateMatches( + in: substring, range: NSRange(location: 0, length: substringLength) + ) { match, _, _ in if let matchRange = match?.range { - let absoluteRange = NSRange(location: range.location + matchRange.location, length: matchRange.length) + let absoluteRange = NSRange( + location: range.location + matchRange.location, length: matchRange.length + ) if !isInsideStringOrComment(absoluteRange) { - textStorage.addAttribute(.foregroundColor, value: SQLEditorTheme.null, range: absoluteRange) + textStorage.addAttribute( + .foregroundColor, value: SQLEditorTheme.null, range: absoluteRange + ) } } } diff --git a/TablePro/Views/Import/ImportDialog.swift b/TablePro/Views/Import/ImportDialog.swift index b71725a49..cefec8a4a 100644 --- a/TablePro/Views/Import/ImportDialog.swift +++ b/TablePro/Views/Import/ImportDialog.swift @@ -357,8 +357,8 @@ struct ImportDialog: View { do { let encoding = config.encoding + let parser = SQLFileParser() let count = try await Task.detached { - let parser = SQLFileParser() return try await parser.countStatements(url: url, encoding: encoding) }.value statementCount = count diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 06befb9de..f9cd5d154 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -87,7 +87,7 @@ struct MainEditorContentView: View { } } .animation(.easeInOut(duration: 0.2), value: appState.isHistoryPanelVisible) - .onChange(of: tabManager.tabs.count) { _ in + .onChange(of: tabManager.tabs.count) { // Clean up sort cache for closed tabs let openTabIds = Set(tabManager.tabs.map(\.id)) sortCache = sortCache.filter { openTabIds.contains($0.key) } @@ -131,15 +131,31 @@ struct MainEditorContentView: View { } } + /// Maximum query size to persist (500KB). Queries larger than this are typically + /// imported SQL dumps — serializing 40MB to JSON + writing to UserDefaults + /// blocks the main thread for 10-30+ seconds, freezing the app. + private static let maxPersistableQuerySize = 500_000 + private func queryTextBinding(for tab: QueryTab) -> Binding { - Binding( + let tabId = tab.id + return Binding( get: { tab.query }, set: { newValue in - guard let index = tabManager.selectedTabIndex, + // Find this tab by ID, not by selectedTabIndex. During tab switch, + // flushTextUpdate() fires on the OLD tab's EditorCoordinator when + // selectedTabIndex already points to the NEW tab — writing to + // selectedTabIndex would overwrite the new tab's query. + guard let index = tabManager.tabs.firstIndex(where: { $0.id == tabId }), index < tabManager.tabs.count else { return } tabManager.tabs[index].query = newValue - coordinator.tabPersistence.saveLastQuery(newValue) + + // Skip persistence for very large queries (e.g., imported SQL dumps). + // JSON-encoding 40MB + writing to UserDefaults freezes the main thread. + let queryLength = (newValue as NSString).length + guard queryLength < Self.maxPersistableQuerySize else { return } + + coordinator.tabPersistence.saveLastQueryDebounced(newValue) if !coordinator.tabPersistence.isRestoringTabs && !coordinator.tabPersistence.isDismissing { coordinator.tabPersistence.saveTabsDebounced( diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 5f4c8c468..d8ebe5f88 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -209,9 +209,13 @@ final class MainContentCoordinator: ObservableObject { queryGeneration += 1 let capturedGeneration = queryGeneration - tabManager.tabs[index].isExecuting = true - tabManager.tabs[index].executionTime = nil - tabManager.tabs[index].errorMessage = nil + // Batch mutations into a single array write to avoid multiple @Published + // notifications — each notification triggers a full SwiftUI update cycle. + var tab = tabManager.tabs[index] + tab.isExecuting = true + tab.executionTime = nil + tab.errorMessage = nil + tabManager.tabs[index] = tab toolbarState.isExecuting = true let conn = connection @@ -330,8 +334,10 @@ final class MainContentCoordinator: ObservableObject { await MainActor.run { currentQueryTask = nil if let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) { - tabManager.tabs[idx].errorMessage = error.localizedDescription - tabManager.tabs[idx].isExecuting = false + var errTab = tabManager.tabs[idx] + errTab.errorMessage = error.localizedDescription + errTab.isExecuting = false + tabManager.tabs[idx] = errTab } toolbarState.isExecuting = false @@ -369,51 +375,59 @@ final class MainContentCoordinator: ObservableObject { } private func extractQueryAtCursor(from fullQuery: String, at position: Int) -> String { - let trimmed = fullQuery.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty, trimmed.contains(";") else { return trimmed } + let nsQuery = fullQuery as NSString + let length = nsQuery.length + guard length > 0 else { return "" } + + // Fast check: if no semicolons, return the full query trimmed. + // Uses NSString range search (C-level speed) instead of Swift String.contains. + guard nsQuery.range(of: ";").location != NSNotFound else { + return fullQuery.trimmingCharacters(in: .whitespacesAndNewlines) + } + + let singleQuote = UInt16(UnicodeScalar("'").value) + let doubleQuote = UInt16(UnicodeScalar("\"").value) + let semicolonChar = UInt16(UnicodeScalar(";").value) - var statements: [(text: String, range: Range)] = [] + let safePosition = min(max(0, position), length) var currentStart = 0 var inString = false - var stringChar: Character = "\"" + var stringCharVal: UInt16 = 0 - for (i, char) in fullQuery.enumerated() { - if char == "'" || char == "\"" { + // Scan through characters, stopping as soon as we find the statement + // containing the cursor. Avoids scanning the entire 40MB file. + for i in 0..= currentStart && safePosition <= stmtEnd { + let stmtRange = NSRange(location: currentStart, length: i - currentStart) + return nsQuery.substring(with: stmtRange) + .trimmingCharacters(in: .whitespacesAndNewlines) } - currentStart = i + 1 + currentStart = stmtEnd } } - if currentStart < fullQuery.count { - let remaining = String(fullQuery[fullQuery.index(fullQuery.startIndex, offsetBy: currentStart)...]) + // Cursor is in the last statement (no trailing semicolon) + if currentStart < length { + let stmtRange = NSRange(location: currentStart, length: length - currentStart) + return nsQuery.substring(with: stmtRange) .trimmingCharacters(in: .whitespacesAndNewlines) - if !remaining.isEmpty { - statements.append((text: remaining, range: currentStart.. 10_000 { // Large dataset: sort on background thread to avoid UI freeze @@ -454,7 +468,7 @@ final class MainContentCoordinator: ObservableObject { let sorted = rows.sorted { row1, row2 in let val1 = row1.values[columnIndex] ?? "" let val2 = row2.values[columnIndex] ?? "" - if sortDirection == .ascending { + if sortAscending { return val1.localizedStandardCompare(val2) == .orderedAscending } else { return val1.localizedStandardCompare(val2) == .orderedDescending @@ -463,21 +477,24 @@ final class MainContentCoordinator: ObservableObject { let sortDuration = Date().timeIntervalSince(sortStartTime) await MainActor.run { [weak self] in + let expectedDirection: SortDirection = sortAscending ? .ascending : .descending guard let self else { return } // Guard against stale completion: verify tab still expects this sort guard let idx = self.tabManager.tabs.firstIndex(where: { $0.id == tabId }), self.tabManager.tabs[idx].sortState.columnIndex == columnIndex, - self.tabManager.tabs[idx].sortState.direction == sortDirection else { + self.tabManager.tabs[idx].sortState.direction == expectedDirection else { return } self.querySortCache[tabId] = QuerySortCacheEntry( rows: sorted, columnIndex: columnIndex, - direction: sortDirection, + direction: expectedDirection, resultVersion: resultVersion ) - self.tabManager.tabs[idx].isExecuting = false - self.tabManager.tabs[idx].executionTime = sortDuration + var sortedTab = self.tabManager.tabs[idx] + sortedTab.isExecuting = false + sortedTab.executionTime = sortDuration + self.tabManager.tabs[idx] = sortedTab self.toolbarState.isExecuting = false self.toolbarState.lastQueryDuration = sortDuration self.activeSortTasks.removeValue(forKey: tabId) diff --git a/TablePro/Views/Main/MainContentNotificationHandler.swift b/TablePro/Views/Main/MainContentNotificationHandler.swift index 09b8ae742..176e4091c 100644 --- a/TablePro/Views/Main/MainContentNotificationHandler.swift +++ b/TablePro/Views/Main/MainContentNotificationHandler.swift @@ -75,6 +75,7 @@ final class MainContentNotificationHandler: ObservableObject { setupDatabaseOperationObservers() setupUndoRedoObservers() setupWindowObservers() + setupFileOpenObservers() } // MARK: - Row Operations @@ -546,6 +547,43 @@ final class MainContentNotificationHandler: ObservableObject { .store(in: &cancellables) } + // MARK: - File Open Operations + + private func setupFileOpenObservers() { + NotificationCenter.default.publisher(for: .openSQLFiles) + .receive(on: DispatchQueue.main) + .sink { [weak self] notification in + self?.handleOpenSQLFiles(notification) + } + .store(in: &cancellables) + } + + private func handleOpenSQLFiles(_ notification: Notification) { + guard let urls = notification.object as? [URL], + let coordinator = coordinator else { return } + + Task { + for url in urls { + let content = await Task.detached(priority: .userInitiated) { () -> String? in + do { + return try String(contentsOf: url, encoding: .utf8) + } catch { + print("[MainContentNotificationHandler] Failed to read " + + "\(url.lastPathComponent): \(error.localizedDescription)") + return nil + } + }.value + + if let content { + coordinator.tabManager.addTab( + initialQuery: content, + title: url.lastPathComponent + ) + } + } + } + } + // MARK: - Window Operations private func setupWindowObservers() { From ab45f7ef09e8897418ff7f11b627e1b2908888ee Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 8 Feb 2026 02:04:00 +0700 Subject: [PATCH 2/3] chore: update Xcode project file --- TablePro.xcodeproj/project.pbxproj | 1 - 1 file changed, 1 deletion(-) diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index db31ad976..3203b2eaf 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -206,7 +206,6 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; - ARCHS = arm64; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; From 9443f5ab60c70294374c222c403cf16bb1f09a6a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 8 Feb 2026 02:16:06 +0700 Subject: [PATCH 3/3] fix: use weak textView ref in debounced task and check full range for string/comment overlap - EditorCoordinator: capture self.textView (weak) in debounced Task instead of strongly capturing the local textView from the notification, avoiding stale reference and stale selectedRange after 100ms delay - SyntaxHighlighter: check full range intersection against stringOrCommentIndices instead of only the start position, correctly detecting keywords that partially overlap string/comment regions --- TablePro/Views/Editor/EditorCoordinator.swift | 2 +- TablePro/Views/Editor/SyntaxHighlighter.swift | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/TablePro/Views/Editor/EditorCoordinator.swift b/TablePro/Views/Editor/EditorCoordinator.swift index c31828776..1c498a887 100644 --- a/TablePro/Views/Editor/EditorCoordinator.swift +++ b/TablePro/Views/Editor/EditorCoordinator.swift @@ -180,7 +180,7 @@ final class EditorCoordinator: NSObject, NSTextViewDelegate { textUpdateTask?.cancel() textUpdateTask = Task { @MainActor [weak self] in try? await Task.sleep(nanoseconds: 100_000_000) // 100ms - guard let self, !Task.isCancelled else { return } + guard let self, let textView = self.textView, !Task.isCancelled else { return } self.isUpdatingFromTextView = true self.textVersion &+= 1 self.lastAppliedTextVersion = self.textVersion diff --git a/TablePro/Views/Editor/SyntaxHighlighter.swift b/TablePro/Views/Editor/SyntaxHighlighter.swift index 4106d0358..a58d60428 100644 --- a/TablePro/Views/Editor/SyntaxHighlighter.swift +++ b/TablePro/Views/Editor/SyntaxHighlighter.swift @@ -454,7 +454,9 @@ final class SyntaxHighlighter: NSObject, NSTextStorageDelegate { } let isInsideStringOrComment: (NSRange) -> Bool = { checkRange in - stringOrCommentIndices.contains(checkRange.location) + !stringOrCommentIndices.intersection( + IndexSet(integersIn: checkRange.location..