diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index c1f852685..3203b2eaf 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 = ""; }; @@ -271,6 +284,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 +374,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 000000000..df6fddace Binary files /dev/null and b/TablePro/Resources/SQLDocument.icns differ diff --git a/TablePro/Views/Connection/ConnectionSidebarHeader.swift b/TablePro/Views/Connection/ConnectionSidebarHeader.swift index e6f85becc..00c7713b6 100644 --- a/TablePro/Views/Connection/ConnectionSidebarHeader.swift +++ b/TablePro/Views/Connection/ConnectionSidebarHeader.swift @@ -38,7 +38,7 @@ struct ConnectionSidebarHeader: View { .renderingMode(.template) .foregroundStyle(session.connection.displayColor) - Text(session.connection.database) + Text(session.connection.name) Spacer() @@ -112,7 +112,9 @@ struct ConnectionSidebarHeader: View { if let session = currentSession { Circle() .fill(statusColor(for: session)) - .frame(width: DesignConstants.IconSize.tinyDot, height: DesignConstants.IconSize.tinyDot) + .frame( + width: DesignConstants.IconSize.tinyDot, + height: DesignConstants.IconSize.tinyDot) } Image(systemName: "chevron.down") @@ -141,7 +143,9 @@ struct ConnectionSidebarHeader: View { HStack(spacing: 4) { Circle() .fill(statusColor(for: session)) - .frame(width: DesignConstants.IconSize.tinyDot, height: DesignConstants.IconSize.tinyDot) + .frame( + width: DesignConstants.IconSize.tinyDot, + height: DesignConstants.IconSize.tinyDot) if case .connecting = session.status { ProgressView() @@ -185,7 +189,7 @@ struct ConnectionSidebarHeader: View { let savedConnections = [ DatabaseConnection(name: "Development DB", type: .mysql), - DatabaseConnection(name: "Staging DB", type: .postgresql) + DatabaseConnection(name: "Staging DB", type: .postgresql), ] return VStack(spacing: 0) { diff --git a/TablePro/Views/Editor/EditorCoordinator.swift b/TablePro/Views/Editor/EditorCoordinator.swift index 1e68dcc8e..1c498a887 100644 --- a/TablePro/Views/Editor/EditorCoordinator.swift +++ b/TablePro/Views/Editor/EditorCoordinator.swift @@ -35,6 +35,13 @@ final class EditorCoordinator: NSObject, NSTextViewDelegate { // Prevent SwiftUI -> 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, let textView = self.textView, !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..a58d60428 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,110 @@ 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.intersection( + IndexSet(integersIn: checkRange.location.. 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() {