From d46b39e7b86791152d8303bbe7c83ce005e3e216 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 5 Jan 2026 19:55:24 +0700 Subject: [PATCH 01/16] perf: optimize SQL editor typing performance - Pre-compile 21 regex patterns in SQLContextAnalyzer as static properties (eliminates regex compilation on every autocomplete call) - Add smart view invalidation in EditorTextView to only redraw affected line regions instead of full view on every keystroke --- .../Autocomplete/SQLContextAnalyzer.swift | 140 +++++++++++------- TablePro/Views/Editor/EditorTextView.swift | 89 ++++++++++- 2 files changed, 175 insertions(+), 54 deletions(-) diff --git a/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift b/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift index 21651d77a..3a32a49a1 100644 --- a/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift +++ b/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift @@ -26,10 +26,11 @@ enum SQLClauseType { case caseExpression // Inside CASE WHEN expression case inList // Inside IN (...) list case limit // After LIMIT/OFFSET - case alterTable // After ALTER TABLE tablename - case createTable // Inside CREATE TABLE definition - case columnDef // Typing column data type (after column name) - case unknown // Unknown or start of query + case alterTable // After ALTER TABLE tablename + case alterTableColumn // After DROP/MODIFY/CHANGE/RENAME COLUMN - need column names + case createTable // Inside CREATE TABLE definition + case columnDef // Typing column data type (after column name) + case unknown // Unknown or start of query } /// Represents a table reference with optional alias @@ -89,6 +90,56 @@ struct SQLContext { /// Analyzes SQL query to determine completion context final class SQLContextAnalyzer { + // MARK: - Cached Regex Patterns (Compiled Once at Class Load) + + /// Pre-compiled clause detection patterns for performance + /// ORDER MATTERS: More specific patterns must come before general ones + private static let clauseRegexes: [(regex: NSRegularExpression, clause: SQLClauseType)] = { + let patterns: [(String, SQLClauseType)] = [ + // DDL patterns (most specific first) + ("\\b(?:ADD|MODIFY|CHANGE)\\s+(?:COLUMN\\s+)?\\w+\\s+\\w*$", .columnDef), + ("\\bALTER\\s+TABLE\\s+[`\"']?\\w+[`\"']?\\s+(?:DROP|MODIFY|CHANGE|RENAME)\\s+(?:COLUMN\\s+)?\\w*$", .alterTableColumn), + ("\\bALTER\\s+TABLE\\s+[^;]*\\bAFTER\\s+\\w*$", .alterTableColumn), + ("\\bALTER\\s+TABLE\\s+[`\"']?\\w+[`\"']?\\s+\\w*$", .alterTable), + ("\\bCREATE\\s+TABLE\\s+[^(]*\\([^)]*$", .createTable), + // Enhanced context patterns + ("\\bIN\\s*\\([^)]*$", .inList), + ("\\bCASE\\s+(?:WHEN\\s+[^;]*)?$", .caseExpression), + ("\\b(LIMIT|OFFSET)\\s+\\d*$", .limit), + // Standard clause patterns + ("\\bVALUES\\s*\\([^)]*$", .values), + ("\\bINSERT\\s+INTO\\s+\\w+\\s*\\([^)]*$", .insertColumns), + ("\\bINTO\\s+\\w*$", .into), + ("\\bSET\\s+[^;]*$", .set), + ("\\bHAVING\\s+[^;]*$", .having), + ("\\bORDER\\s+BY\\s+[^;]*$", .orderBy), + ("\\bGROUP\\s+BY\\s+[^;]*$", .groupBy), + ("\\b(AND|OR)\\s+\\w*$", .and), + ("\\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), + ("\\bJOIN\\s+[`\"']?\\w*[`\"']?\\s*$", .join), + // FROM patterns + ("\\bFROM\\s+[`\"']?\\w+[`\"']?(?:\\s+(?:AS\\s+)?\\w+)?\\s*$", .from), + ("\\bFROM\\s+\\w*$", .from), + // SELECT is most general + ("\\bSELECT\\s+[^;]*$", .select), + ] + return patterns.compactMap { pattern, clause in + guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) else { + return nil + } + return (regex, clause) + } + }() + + /// Pre-compiled regex for removing strings and comments + private static let singleQuoteStringRegex = try? NSRegularExpression(pattern: "'[^']*'") + private static let doubleQuoteStringRegex = try? NSRegularExpression(pattern: "\"[^\"]*\"") + private static let blockCommentRegex = try? NSRegularExpression(pattern: "/\\*[\\s\\S]*?\\*/") + private static let lineCommentRegex = try? NSRegularExpression(pattern: "--[^\n]*") + // MARK: - Main Analysis /// Analyze the query at the given cursor position @@ -143,6 +194,14 @@ final class SQLContextAnalyzer { } } + // Extract ALTER TABLE table name and add to references + if let alterTableName = extractAlterTableName(from: currentStatement) { + let alterRef = TableReference(tableName: alterTableName, alias: nil) + if !tableReferences.contains(alterRef) { + tableReferences.append(alterRef) + } + } + // Calculate nesting level (subquery depth) let nestingLevel = calculateNestingLevel(in: textBeforeCursor) @@ -511,6 +570,22 @@ final class SQLContextAnalyzer { return references } + /// Extract table name from ALTER TABLE statement + private func extractAlterTableName(from query: String) -> String? { + // Pattern: ALTER TABLE tablename + let pattern = "(?i)\\bALTER\\s+TABLE\\s+[`\"']?([\\w]+)[`\"']?" + + guard let regex = try? NSRegularExpression(pattern: pattern) 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]) + } + + return nil + } + /// Determine the clause type based on text before cursor private func determineClauseType(textBeforeCursor: String, dotPrefix: String?, currentFunction: String? = nil) -> SQLClauseType { // If we have a dot prefix, we're looking for columns @@ -528,46 +603,10 @@ final class SQLContextAnalyzer { // Remove string literals and comments for analysis let cleaned = removeStringsAndComments(from: upper) - // Find the last keyword to determine context - // ORDER MATTERS: More specific patterns must come before general ones - let clausePatterns: [(pattern: String, clause: SQLClauseType)] = [ - // DDL patterns (most specific first) - // After ADD/MODIFY COLUMN name - suggest data types - ("\\b(?:ADD|MODIFY|CHANGE)\\s+(?:COLUMN\\s+)?\\w+\\s+\\w*$", .columnDef), - // After ALTER TABLE tablename - suggest ADD, DROP, MODIFY, etc. - ("\\bALTER\\s+TABLE\\s+[`\"']?\\w+[`\"']?\\s+\\w*$", .alterTable), - // Inside CREATE TABLE (...) - suggest column definitions - ("\\bCREATE\\s+TABLE\\s+[^(]*\\([^)]*$", .createTable), - - // New patterns for enhanced context - ("\\bIN\\s*\\([^)]*$", .inList), - ("\\bCASE\\s+(?:WHEN\\s+[^;]*)?$", .caseExpression), - ("\\b(LIMIT|OFFSET)\\s+\\d*$", .limit), - - // Existing patterns - ("\\bVALUES\\s*\\([^)]*$", .values), - ("\\bINSERT\\s+INTO\\s+\\w+\\s*\\([^)]*$", .insertColumns), - ("\\bINTO\\s+\\w*$", .into), - ("\\bSET\\s+[^;]*$", .set), - ("\\bHAVING\\s+[^;]*$", .having), - ("\\bORDER\\s+BY\\s+[^;]*$", .orderBy), - ("\\bGROUP\\s+BY\\s+[^;]*$", .groupBy), - ("\\b(AND|OR)\\s+\\w*$", .and), - ("\\bWHERE\\s+[^;]*$", .where_), - ("\\bON\\s+[^;]*$", .on), - // JOIN: match various JOIN types followed by table [alias] - must come before FROM - ("(?:LEFT|RIGHT|INNER|OUTER|FULL|CROSS)?\\s*(?:OUTER)?\\s*JOIN\\s+[`\"']?\\w+[`\"']?(?:\\s+(?:AS\\s+)?\\w+)?\\s*$", .join), - ("\\bJOIN\\s+[`\"']?\\w*[`\"']?\\s*$", .join), - // FROM: match "FROM table" or "FROM table " (with or without trailing space) - NOT followed by WHERE/ORDER/etc. - ("\\bFROM\\s+[`\"']?\\w+[`\"']?(?:\\s+(?:AS\\s+)?\\w+)?\\s*$", .from), - ("\\bFROM\\s+\\w*$", .from), - // SELECT comes last as it's the most general - ("\\bSELECT\\s+[^;]*$", .select), - ] - - for (pattern, clause) in clausePatterns { - if let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive), - regex.firstMatch(in: cleaned, range: NSRange(cleaned.startIndex..., in: cleaned)) != nil { + // Use pre-compiled regex patterns for performance + let range = NSRange(cleaned.startIndex..., in: cleaned) + for (regex, clause) in Self.clauseRegexes { + if regex.firstMatch(in: cleaned, range: range) != nil { return clause } } @@ -579,23 +618,20 @@ final class SQLContextAnalyzer { private func removeStringsAndComments(from text: String) -> String { var result = text - // Remove single-quoted strings - if let regex = try? NSRegularExpression(pattern: "'[^']*'") { + // Use pre-compiled regex patterns for performance + if let regex = Self.singleQuoteStringRegex { result = regex.stringByReplacingMatches(in: result, range: NSRange(result.startIndex..., in: result), withTemplate: "''") } - // Remove double-quoted strings - if let regex = try? NSRegularExpression(pattern: "\"[^\"]*\"") { + if let regex = Self.doubleQuoteStringRegex { result = regex.stringByReplacingMatches(in: result, range: NSRange(result.startIndex..., in: result), withTemplate: "\"\"") } - // Remove block comments - if let regex = try? NSRegularExpression(pattern: "/\\*[\\s\\S]*?\\*/") { + if let regex = Self.blockCommentRegex { result = regex.stringByReplacingMatches(in: result, range: NSRange(result.startIndex..., in: result), withTemplate: "") } - // Remove line comments - if let regex = try? NSRegularExpression(pattern: "--[^\n]*") { + if let regex = Self.lineCommentRegex { result = regex.stringByReplacingMatches(in: result, range: NSRange(result.startIndex..., in: result), withTemplate: "") } diff --git a/TablePro/Views/Editor/EditorTextView.swift b/TablePro/Views/Editor/EditorTextView.swift index 32f1123ad..dbad1b61f 100644 --- a/TablePro/Views/Editor/EditorTextView.swift +++ b/TablePro/Views/Editor/EditorTextView.swift @@ -56,6 +56,9 @@ final class EditorTextView: NSTextView { commonInit() } + /// Track the last cursor position for smart invalidation + private var lastCursorLine: Int = -1 + private func commonInit() { // Observe selection changes for visual updates NotificationCenter.default.addObserver( @@ -71,8 +74,90 @@ final class EditorTextView: NSTextView { } @objc private func selectionDidChange(_ notification: Notification) { - // Trigger redraw for current line highlight and bracket matching - needsDisplay = true + // Smart invalidation: only redraw the affected line regions + // instead of the entire view + invalidateLineHighlightIfNeeded() + } + + /// Invalidate only the current and previous line regions for redraw + private func invalidateLineHighlightIfNeeded() { + guard let layoutManager = layoutManager, + let textContainer = textContainer else { + needsDisplay = true + return + } + + let cursorPos = selectedRange().location + + // Calculate current line + let currentLine: Int + if string.isEmpty { + currentLine = 0 + } else if cursorPos >= string.count { + currentLine = string.filter { $0 == "\n" }.count + } else { + let index = string.index(string.startIndex, offsetBy: cursorPos) + currentLine = string[..= 0 { + if let rect = lineRectForLine(lastCursorLine, layoutManager: layoutManager, textContainer: textContainer) { + setNeedsDisplay(rect.insetBy(dx: -2, dy: -2)) + } + } + + // Invalidate the current line rect + if let rect = lineRectForLine(currentLine, layoutManager: layoutManager, textContainer: textContainer) { + setNeedsDisplay(rect.insetBy(dx: -2, dy: -2)) + } + + lastCursorLine = currentLine + } + + /// Get the rect for a specific line number + private func lineRectForLine(_ lineNumber: Int, layoutManager: NSLayoutManager, textContainer: NSTextContainer) -> NSRect? { + guard layoutManager.numberOfGlyphs > 0 else { return nil } + + // Find the character index for the start of the line + var currentLine = 0 + var charIndex = 0 + let text = string + + for (index, char) in text.enumerated() { + if currentLine == lineNumber { + charIndex = index + break + } + if char == "\n" { + currentLine += 1 + } + } + + // Handle cursor at end of text + if currentLine < lineNumber { + charIndex = text.count > 0 ? text.count - 1 : 0 + } + + layoutManager.ensureLayout(for: textContainer) + + let glyphIndex = layoutManager.glyphIndexForCharacter(at: min(charIndex, max(0, text.count - 1))) + guard glyphIndex < layoutManager.numberOfGlyphs else { return nil } + + var lineRect = layoutManager.lineFragmentRect(forGlyphAt: glyphIndex, effectiveRange: nil) + + // Adjust for text container origin + let origin = textContainerOrigin + lineRect.origin.x = origin.x + lineRect.origin.y += origin.y + lineRect.size.width = bounds.width - origin.x * 2 + + return lineRect } // MARK: - Drawing From 8fafa295af0de5d71a7fd6dbfccea25aacb00ead Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 5 Jan 2026 20:01:14 +0700 Subject: [PATCH 02/16] Update TablePro/Core/Autocomplete/SQLContextAnalyzer.swift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Ngô Quốc Đạt --- TablePro/Core/Autocomplete/SQLContextAnalyzer.swift | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift b/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift index 3a32a49a1..975d2df2d 100644 --- a/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift +++ b/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift @@ -570,12 +570,16 @@ final class SQLContextAnalyzer { return references } - /// Extract table name from ALTER TABLE statement - private func extractAlterTableName(from query: String) -> String? { + /// Pre-compiled regex for extracting table name from ALTER TABLE statements + private static let alterTableRegex: NSRegularExpression? = { // Pattern: ALTER TABLE tablename let pattern = "(?i)\\bALTER\\s+TABLE\\s+[`\"']?([\\w]+)[`\"']?" - - guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } + return try? NSRegularExpression(pattern: pattern) + }() + + /// Extract table name from ALTER TABLE statement + 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), From 028b126562d26a764250232a958ee977895f31d7 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 5 Jan 2026 21:07:38 +0700 Subject: [PATCH 03/16] wip --- TablePro/Views/Editor/EditorTextView.swift | 44 +++++++++++++--------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/TablePro/Views/Editor/EditorTextView.swift b/TablePro/Views/Editor/EditorTextView.swift index dbad1b61f..7939fbb66 100644 --- a/TablePro/Views/Editor/EditorTextView.swift +++ b/TablePro/Views/Editor/EditorTextView.swift @@ -89,15 +89,17 @@ final class EditorTextView: NSTextView { let cursorPos = selectedRange().location - // Calculate current line + // Calculate current line efficiently without intermediate arrays let currentLine: Int if string.isEmpty { currentLine = 0 } else if cursorPos >= string.count { - currentLine = string.filter { $0 == "\n" }.count + // Count total newlines in string + currentLine = string.reduce(0) { $0 + ($1 == "\n" ? 1 : 0) } } else { + // Count newlines up to cursor position let index = string.index(string.startIndex, offsetBy: cursorPos) - currentLine = string[.. NSRect? { guard layoutManager.numberOfGlyphs > 0 else { return nil } - // Find the character index for the start of the line + let text = string as NSString + guard text.length > 0 else { return nil } + + // Find the character index for the target line using NSString's lineRange var currentLine = 0 var charIndex = 0 - let text = string + var searchRange = NSRange(location: 0, length: text.length) - for (index, char) in text.enumerated() { - if currentLine == lineNumber { - charIndex = index - break - } - if char == "\n" { - currentLine += 1 - } + while currentLine < lineNumber && searchRange.location < text.length { + let lineRange = text.lineRange(for: searchRange) + charIndex = lineRange.location + currentLine += 1 + + // Move to next line + searchRange.location = NSMaxRange(lineRange) + searchRange.length = text.length - searchRange.location } - // Handle cursor at end of text - if currentLine < lineNumber { - charIndex = text.count > 0 ? text.count - 1 : 0 + // If we found the line, use its start position + if currentLine == lineNumber { + // Use the actual line start position we found + } else { + // Line number is beyond document, clamp to last valid position + charIndex = max(0, text.length - 1) } layoutManager.ensureLayout(for: textContainer) - let glyphIndex = layoutManager.glyphIndexForCharacter(at: min(charIndex, max(0, text.count - 1))) + let glyphIndex = layoutManager.glyphIndexForCharacter(at: min(charIndex, max(0, text.length - 1))) guard glyphIndex < layoutManager.numberOfGlyphs else { return nil } var lineRect = layoutManager.lineFragmentRect(forGlyphAt: glyphIndex, effectiveRange: nil) From 5383d640d3c56945d34b4d624d3561e7bbd50954 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 5 Jan 2026 21:13:56 +0700 Subject: [PATCH 04/16] Update TablePro/Views/Editor/EditorTextView.swift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Ngô Quốc Đạt --- TablePro/Views/Editor/EditorTextView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TablePro/Views/Editor/EditorTextView.swift b/TablePro/Views/Editor/EditorTextView.swift index 7939fbb66..ef354d3cf 100644 --- a/TablePro/Views/Editor/EditorTextView.swift +++ b/TablePro/Views/Editor/EditorTextView.swift @@ -154,7 +154,7 @@ final class EditorTextView: NSTextView { layoutManager.ensureLayout(for: textContainer) - let glyphIndex = layoutManager.glyphIndexForCharacter(at: min(charIndex, max(0, text.length - 1))) + let glyphIndex = layoutManager.glyphIndexForCharacter(at: charIndex) guard glyphIndex < layoutManager.numberOfGlyphs else { return nil } var lineRect = layoutManager.lineFragmentRect(forGlyphAt: glyphIndex, effectiveRange: nil) From b8c9c84aa0a0275ed416b8b6d70fad95597de798 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 5 Jan 2026 21:14:07 +0700 Subject: [PATCH 05/16] Update TablePro/Core/Autocomplete/SQLContextAnalyzer.swift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Ngô Quốc Đạt --- .../Core/Autocomplete/SQLContextAnalyzer.swift | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift b/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift index 975d2df2d..dd4797ee4 100644 --- a/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift +++ b/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift @@ -126,19 +126,17 @@ final class SQLContextAnalyzer { // SELECT is most general ("\\bSELECT\\s+[^;]*$", .select), ] - return patterns.compactMap { pattern, clause in - guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) else { - return nil - } + return patterns.map { pattern, clause in + let regex = try! NSRegularExpression(pattern: pattern, options: .caseInsensitive) return (regex, clause) } }() /// Pre-compiled regex for removing strings and comments - private static let singleQuoteStringRegex = try? NSRegularExpression(pattern: "'[^']*'") - private static let doubleQuoteStringRegex = try? NSRegularExpression(pattern: "\"[^\"]*\"") - private static let blockCommentRegex = try? NSRegularExpression(pattern: "/\\*[\\s\\S]*?\\*/") - private static let lineCommentRegex = try? NSRegularExpression(pattern: "--[^\n]*") + private static let singleQuoteStringRegex = try! NSRegularExpression(pattern: "'[^']*'") + private static let doubleQuoteStringRegex = try! NSRegularExpression(pattern: "\"[^\"]*\"") + private static let blockCommentRegex = try! NSRegularExpression(pattern: "/\\*[\\s\\S]*?\\*/") + private static let lineCommentRegex = try! NSRegularExpression(pattern: "--[^\n]*") // MARK: - Main Analysis From f7bd4cb2e7f241897f65a2ae623f5ef7a9e31e25 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 5 Jan 2026 21:16:31 +0700 Subject: [PATCH 06/16] wip --- TablePro/Views/Editor/EditorTextView.swift | 38 ++++++++++++++-------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/TablePro/Views/Editor/EditorTextView.swift b/TablePro/Views/Editor/EditorTextView.swift index ef354d3cf..e8ddea0d8 100644 --- a/TablePro/Views/Editor/EditorTextView.swift +++ b/TablePro/Views/Editor/EditorTextView.swift @@ -89,17 +89,25 @@ final class EditorTextView: NSTextView { let cursorPos = selectedRange().location - // Calculate current line efficiently without intermediate arrays + // Calculate current line using simple loop (no closure overhead or intermediate arrays) let currentLine: Int if string.isEmpty { currentLine = 0 } else if cursorPos >= string.count { // Count total newlines in string - currentLine = string.reduce(0) { $0 + ($1 == "\n" ? 1 : 0) } + var count = 0 + for char in string { + if char == "\n" { count += 1 } + } + currentLine = count } else { // Count newlines up to cursor position let index = string.index(string.startIndex, offsetBy: cursorPos) - currentLine = string[.. 0 else { return nil } // Find the character index for the target line using NSString's lineRange - var currentLine = 0 var charIndex = 0 var searchRange = NSRange(location: 0, length: text.length) - while currentLine < lineNumber && searchRange.location < text.length { + // Iterate to the target line + for _ in 0.. Date: Mon, 5 Jan 2026 21:22:08 +0700 Subject: [PATCH 07/16] Update TablePro/Views/Editor/EditorTextView.swift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Ngô Quốc Đạt --- TablePro/Views/Editor/EditorTextView.swift | 48 +++++++++++++++------- 1 file changed, 34 insertions(+), 14 deletions(-) diff --git a/TablePro/Views/Editor/EditorTextView.swift b/TablePro/Views/Editor/EditorTextView.swift index e8ddea0d8..0fcb65a90 100644 --- a/TablePro/Views/Editor/EditorTextView.swift +++ b/TablePro/Views/Editor/EditorTextView.swift @@ -89,25 +89,45 @@ final class EditorTextView: NSTextView { let cursorPos = selectedRange().location - // Calculate current line using simple loop (no closure overhead or intermediate arrays) + // Calculate current line using NSString's line APIs to avoid per-character scanning let currentLine: Int if string.isEmpty { currentLine = 0 - } else if cursorPos >= string.count { - // Count total newlines in string - var count = 0 - for char in string { - if char == "\n" { count += 1 } - } - currentLine = count } else { - // Count newlines up to cursor position - let index = string.index(string.startIndex, offsetBy: cursorPos) - var count = 0 - for char in string[.. clampedCursorPos { + break + } + + lineNumber += 1 + index = lineEnd } - currentLine = count + + currentLine = lineNumber } // Skip if cursor is on the same line From f2dcabbcd6027f30a7fe51334d4766c0c36224c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 5 Jan 2026 21:22:18 +0700 Subject: [PATCH 08/16] Update TablePro/Core/Autocomplete/SQLContextAnalyzer.swift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Ngô Quốc Đạt --- TablePro/Core/Autocomplete/SQLContextAnalyzer.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift b/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift index dd4797ee4..1ac7fadac 100644 --- a/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift +++ b/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift @@ -570,8 +570,8 @@ final class SQLContextAnalyzer { /// Pre-compiled regex for extracting table name from ALTER TABLE statements private static let alterTableRegex: NSRegularExpression? = { - // Pattern: ALTER TABLE tablename - let pattern = "(?i)\\bALTER\\s+TABLE\\s+[`\"']?([\\w]+)[`\"']?" + // Pattern: ALTER TABLE tablename (supports optional quoting and special characters) + let pattern = "(?i)\\bALTER\\s+TABLE\\s+[`\"']?([^`\"']+)[`\"']?" return try? NSRegularExpression(pattern: pattern) }() From 70a02f09c06a012cbfe7af830836fbb957e90d95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 5 Jan 2026 21:22:28 +0700 Subject: [PATCH 09/16] Update TablePro/Core/Autocomplete/SQLContextAnalyzer.swift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Ngô Quốc Đạt --- TablePro/Core/Autocomplete/SQLContextAnalyzer.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift b/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift index 1ac7fadac..d149c622a 100644 --- a/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift +++ b/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift @@ -126,8 +126,11 @@ final class SQLContextAnalyzer { // SELECT is most general ("\\bSELECT\\s+[^;]*$", .select), ] - return patterns.map { pattern, clause in - let regex = try! NSRegularExpression(pattern: pattern, options: .caseInsensitive) + return patterns.compactMap { pattern, clause in + guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) else { + assertionFailure("Invalid SQL clause regex pattern: \(pattern)") + return nil + } return (regex, clause) } }() From f421fcb85be0be79bba3e5223859d447c3edcdbf Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 5 Jan 2026 21:24:32 +0700 Subject: [PATCH 10/16] wip --- .../Autocomplete/SQLContextAnalyzer.swift | 65 ++++++++++++++----- TablePro/Views/Editor/EditorTextView.swift | 36 +++++++--- 2 files changed, 76 insertions(+), 25 deletions(-) diff --git a/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift b/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift index d149c622a..022c9c26a 100644 --- a/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift +++ b/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift @@ -135,11 +135,34 @@ final class SQLContextAnalyzer { } }() - /// Pre-compiled regex for removing strings and comments - private static let singleQuoteStringRegex = try! NSRegularExpression(pattern: "'[^']*'") - private static let doubleQuoteStringRegex = try! NSRegularExpression(pattern: "\"[^\"]*\"") - private static let blockCommentRegex = try! NSRegularExpression(pattern: "/\\*[\\s\\S]*?\\*/") - private static let lineCommentRegex = try! NSRegularExpression(pattern: "--[^\n]*") + /// Pre-compiled regex for removing strings and comments (force-unwrap safe: simple patterns) + private static let singleQuoteStringRegex: NSRegularExpression = { + guard let regex = try? NSRegularExpression(pattern: "'[^']*'") else { + fatalError("Failed to compile singleQuoteStringRegex - invalid pattern") + } + return regex + }() + + private static let doubleQuoteStringRegex: NSRegularExpression = { + guard let regex = try? NSRegularExpression(pattern: "\"[^\"]*\"") else { + fatalError("Failed to compile doubleQuoteStringRegex - invalid pattern") + } + return regex + }() + + private static let blockCommentRegex: NSRegularExpression = { + guard let regex = try? NSRegularExpression(pattern: "/\\*[\\s\\S]*?\\*/") else { + fatalError("Failed to compile blockCommentRegex - invalid pattern") + } + return regex + }() + + private static let lineCommentRegex: NSRegularExpression = { + guard let regex = try? NSRegularExpression(pattern: "--[^\n]*") else { + fatalError("Failed to compile lineCommentRegex - invalid pattern") + } + return regex + }() // MARK: - Main Analysis @@ -624,21 +647,29 @@ final class SQLContextAnalyzer { var result = text // Use pre-compiled regex patterns for performance - if let regex = Self.singleQuoteStringRegex { - result = regex.stringByReplacingMatches(in: result, range: NSRange(result.startIndex..., in: result), withTemplate: "''") - } + result = Self.singleQuoteStringRegex.stringByReplacingMatches( + in: result, + range: NSRange(result.startIndex..., in: result), + withTemplate: "''" + ) - if let regex = Self.doubleQuoteStringRegex { - result = regex.stringByReplacingMatches(in: result, range: NSRange(result.startIndex..., in: result), withTemplate: "\"\"") - } + result = Self.doubleQuoteStringRegex.stringByReplacingMatches( + in: result, + range: NSRange(result.startIndex..., in: result), + withTemplate: "\"\"" + ) - if let regex = Self.blockCommentRegex { - result = regex.stringByReplacingMatches(in: result, range: NSRange(result.startIndex..., in: result), withTemplate: "") - } + result = Self.blockCommentRegex.stringByReplacingMatches( + in: result, + range: NSRange(result.startIndex..., in: result), + withTemplate: "" + ) - if let regex = Self.lineCommentRegex { - result = regex.stringByReplacingMatches(in: result, range: NSRange(result.startIndex..., in: result), withTemplate: "") - } + result = Self.lineCommentRegex.stringByReplacingMatches( + in: result, + range: NSRange(result.startIndex..., in: result), + withTemplate: "" + ) return result } diff --git a/TablePro/Views/Editor/EditorTextView.swift b/TablePro/Views/Editor/EditorTextView.swift index 0fcb65a90..60f593d14 100644 --- a/TablePro/Views/Editor/EditorTextView.swift +++ b/TablePro/Views/Editor/EditorTextView.swift @@ -24,6 +24,9 @@ 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 + // MARK: - Auto-Pairing Configuration private let bracketPairs: [Character: Character] = [ @@ -56,9 +59,6 @@ final class EditorTextView: NSTextView { commonInit() } - /// Track the last cursor position for smart invalidation - private var lastCursorLine: Int = -1 - private func commonInit() { // Observe selection changes for visual updates NotificationCenter.default.addObserver( @@ -150,6 +150,9 @@ final class EditorTextView: NSTextView { lastCursorLine = currentLine } + /// Simple cache for line lookups to avoid repeated O(n) scans for consecutive lines + private var lineCache: (lastLine: Int, charIndex: Int, searchRange: NSRange)? + /// Get the rect for a specific line number using efficient NSString lineRange private func lineRectForLine(_ lineNumber: Int, layoutManager: NSLayoutManager, textContainer: NSTextContainer) -> NSRect? { guard layoutManager.numberOfGlyphs > 0 else { return nil } @@ -157,15 +160,31 @@ final class EditorTextView: NSTextView { let text = string as NSString guard text.length > 0 else { return nil } - // Find the character index for the target line using NSString's lineRange var charIndex = 0 var searchRange = NSRange(location: 0, length: text.length) + var startLine = 0 + + // Use cache if we're looking for a nearby line (common case: prev/current line) + if let cache = lineCache, abs(cache.lastLine - lineNumber) <= 1 { + if cache.lastLine == lineNumber { + // Exact cache hit + charIndex = cache.charIndex + searchRange = cache.searchRange + startLine = lineNumber + } else if cache.lastLine + 1 == lineNumber { + // Next line after cached (common: moving from prev to current) + charIndex = cache.charIndex + searchRange = cache.searchRange + startLine = cache.lastLine + } + } - // Iterate to the target line - for _ in 0.. Date: Mon, 5 Jan 2026 21:27:41 +0700 Subject: [PATCH 11/16] wip --- TablePro/Core/Autocomplete/SQLCompletionProvider.swift | 7 +++++++ TablePro/Core/Utilities/SQLFileParser.swift | 4 +--- TablePro/Views/Editor/EditorTextView.swift | 2 +- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/TablePro/Core/Autocomplete/SQLCompletionProvider.swift b/TablePro/Core/Autocomplete/SQLCompletionProvider.swift index ae5b26e86..6b18e243a 100644 --- a/TablePro/Core/Autocomplete/SQLCompletionProvider.swift +++ b/TablePro/Core/Autocomplete/SQLCompletionProvider.swift @@ -153,6 +153,13 @@ final class SQLCompletionProvider { "CONSTRAINT", "ENGINE", "CHARSET", "COLLATE" ]) + case .alterTableColumn: + // After ALTER TABLE tablename DROP/MODIFY/CHANGE COLUMN - suggest column names + if let firstTable = context.tableReferences.first { + items = await schemaProvider.columnCompletionItems(for: firstTable.tableName) + } + items += filterKeywords(["COLUMN", "FIRST", "AFTER"]) + case .createTable: // Inside CREATE TABLE (...) - suggest constraints and data types items = filterKeywords([ diff --git a/TablePro/Core/Utilities/SQLFileParser.swift b/TablePro/Core/Utilities/SQLFileParser.swift index f2ad23d7a..3afe14e50 100644 --- a/TablePro/Core/Utilities/SQLFileParser.swift +++ b/TablePro/Core/Utilities/SQLFileParser.swift @@ -247,9 +247,7 @@ final class SQLFileParser { } catch { // Log parsing errors - these should not fail silently print("ERROR: SQL file parsing failed: \(error.localizedDescription)") - if let fileError = error as? NSError { - print("Error details: domain=\(fileError.domain), code=\(fileError.code)") - } + print("Error details: \(error)") continuation.finish() } } diff --git a/TablePro/Views/Editor/EditorTextView.swift b/TablePro/Views/Editor/EditorTextView.swift index 60f593d14..ec2e6bc96 100644 --- a/TablePro/Views/Editor/EditorTextView.swift +++ b/TablePro/Views/Editor/EditorTextView.swift @@ -112,7 +112,7 @@ final class EditorTextView: NSTextView { nsString.getLineStart(&lineStart, end: &lineEnd, contentsEnd: &contentsEnd, - forRange: NSRange(location: index, length: 0)) + for: NSRange(location: index, length: 0)) // If we've reached the last line, stop if lineEnd <= index { From e8b42a493fb0e14e003476be9d1842eb2da8c72b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 5 Jan 2026 21:31:26 +0700 Subject: [PATCH 12/16] Update TablePro/Core/Autocomplete/SQLContextAnalyzer.swift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Ngô Quốc Đạt --- .../Autocomplete/SQLContextAnalyzer.swift | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift b/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift index 022c9c26a..234dc4088 100644 --- a/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift +++ b/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift @@ -137,31 +137,39 @@ final class SQLContextAnalyzer { /// Pre-compiled regex for removing strings and comments (force-unwrap safe: simple patterns) private static let singleQuoteStringRegex: NSRegularExpression = { - guard let regex = try? NSRegularExpression(pattern: "'[^']*'") else { - fatalError("Failed to compile singleQuoteStringRegex - invalid pattern") + if let regex = try? NSRegularExpression(pattern: "'[^']*'") { + return regex } - return regex + assertionFailure("Failed to compile singleQuoteStringRegex - invalid pattern") + // Fallback to a regex that matches nothing + return try! NSRegularExpression(pattern: "(?!)") }() private static let doubleQuoteStringRegex: NSRegularExpression = { - guard let regex = try? NSRegularExpression(pattern: "\"[^\"]*\"") else { - fatalError("Failed to compile doubleQuoteStringRegex - invalid pattern") + if let regex = try? NSRegularExpression(pattern: "\"[^\"]*\"") { + return regex } - return regex + assertionFailure("Failed to compile doubleQuoteStringRegex - invalid pattern") + // Fallback to a regex that matches nothing + return try! NSRegularExpression(pattern: "(?!)") }() private static let blockCommentRegex: NSRegularExpression = { - guard let regex = try? NSRegularExpression(pattern: "/\\*[\\s\\S]*?\\*/") else { - fatalError("Failed to compile blockCommentRegex - invalid pattern") + if let regex = try? NSRegularExpression(pattern: "/\\*[\\s\\S]*?\\*/") { + return regex } - return regex + assertionFailure("Failed to compile blockCommentRegex - invalid pattern") + // Fallback to a regex that matches nothing + return try! NSRegularExpression(pattern: "(?!)") }() private static let lineCommentRegex: NSRegularExpression = { - guard let regex = try? NSRegularExpression(pattern: "--[^\n]*") else { - fatalError("Failed to compile lineCommentRegex - invalid pattern") + if let regex = try? NSRegularExpression(pattern: "--[^\n]*") { + return regex } - return regex + assertionFailure("Failed to compile lineCommentRegex - invalid pattern") + // Fallback to a regex that matches nothing + return try! NSRegularExpression(pattern: "(?!)") }() // MARK: - Main Analysis From 21421b2b0b961fe86348de8f62babceee89f3c07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 5 Jan 2026 21:31:53 +0700 Subject: [PATCH 13/16] Update TablePro/Views/Editor/EditorTextView.swift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Ngô Quốc Đạt --- TablePro/Views/Editor/EditorTextView.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/TablePro/Views/Editor/EditorTextView.swift b/TablePro/Views/Editor/EditorTextView.swift index ec2e6bc96..b3d2b30b0 100644 --- a/TablePro/Views/Editor/EditorTextView.swift +++ b/TablePro/Views/Editor/EditorTextView.swift @@ -89,7 +89,9 @@ final class EditorTextView: NSTextView { let cursorPos = selectedRange().location - // Calculate current line using NSString's line APIs to avoid per-character scanning + // 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 From 5fc1109592a5fbc9fd2ab2f21b68c07b61d520bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 5 Jan 2026 21:32:02 +0700 Subject: [PATCH 14/16] Update TablePro/Views/Editor/EditorTextView.swift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Ngô Quốc Đạt --- TablePro/Views/Editor/EditorTextView.swift | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/TablePro/Views/Editor/EditorTextView.swift b/TablePro/Views/Editor/EditorTextView.swift index b3d2b30b0..d68710eab 100644 --- a/TablePro/Views/Editor/EditorTextView.swift +++ b/TablePro/Views/Editor/EditorTextView.swift @@ -152,7 +152,19 @@ final class EditorTextView: NSTextView { lastCursorLine = currentLine } - /// Simple cache for line lookups to avoid repeated O(n) scans for consecutive lines + /// Simple cache for line lookups to avoid repeated O(n) scans for consecutive lines. + /// + /// NOTE: + /// - This cache is shared by both `invalidateLineHighlightIfNeeded()` (which typically + /// queries the current and previous cursor lines) and generic callers of + /// `lineRectForLine(_:,layoutManager:textContainer:)`, which may request any line. + /// - The cache only provides a benefit when the requested line is the same as, or + /// adjacent to, the last cached line (see the `abs(cache.lastLine - lineNumber) <= 1` + /// check in `lineRectForLine`). Calls for distant line numbers will effectively + /// overwrite the cache and may reduce its effectiveness for cursor-movement tracking. + /// - This limitation is intentional: the cache is an opportunistic optimization and + /// must not be relied upon for correctness or for guaranteeing fast lookups for + /// arbitrary line numbers. private var lineCache: (lastLine: Int, charIndex: Int, searchRange: NSRange)? /// Get the rect for a specific line number using efficient NSString lineRange From ff4a18bab1a26ac64e1a1418b86f0ad421e711d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 5 Jan 2026 21:33:10 +0700 Subject: [PATCH 15/16] Update TablePro/Core/Autocomplete/SQLContextAnalyzer.swift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Ngô Quốc Đạt --- TablePro/Core/Autocomplete/SQLContextAnalyzer.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift b/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift index 234dc4088..abda8f808 100644 --- a/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift +++ b/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift @@ -98,7 +98,7 @@ final class SQLContextAnalyzer { let patterns: [(String, SQLClauseType)] = [ // DDL patterns (most specific first) ("\\b(?:ADD|MODIFY|CHANGE)\\s+(?:COLUMN\\s+)?\\w+\\s+\\w*$", .columnDef), - ("\\bALTER\\s+TABLE\\s+[`\"']?\\w+[`\"']?\\s+(?:DROP|MODIFY|CHANGE|RENAME)\\s+(?:COLUMN\\s+)?\\w*$", .alterTableColumn), + ("\\bALTER\\s+TABLE\\s+[`\"']?\\w+[`\"']?\\s+(?:DROP|MODIFY|CHANGE|RENAME)\\s+(?:COLUMN\\s+)?(?:[`\"']?\\w+[`\"']?)?\\s*$", .alterTableColumn), ("\\bALTER\\s+TABLE\\s+[^;]*\\bAFTER\\s+\\w*$", .alterTableColumn), ("\\bALTER\\s+TABLE\\s+[`\"']?\\w+[`\"']?\\s+\\w*$", .alterTable), ("\\bCREATE\\s+TABLE\\s+[^(]*\\([^)]*$", .createTable), From 7f642d8ff0f2af3155ac04efaaa2f26a925f1dd5 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 5 Jan 2026 21:33:07 +0700 Subject: [PATCH 16/16] wip --- TablePro/Views/Editor/EditorTextView.swift | 45 +++++++++++++++++----- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/TablePro/Views/Editor/EditorTextView.swift b/TablePro/Views/Editor/EditorTextView.swift index d68710eab..2d2864abb 100644 --- a/TablePro/Views/Editor/EditorTextView.swift +++ b/TablePro/Views/Editor/EditorTextView.swift @@ -27,6 +27,9 @@ final class EditorTextView: NSTextView { /// Track the last cursor position for smart invalidation private var lastCursorLine: Int = -1 + /// Margin to expand invalidation rect to ensure borders/effects are redrawn + private let lineInvalidationMargin: CGFloat = 2 + // MARK: - Auto-Pairing Configuration private let bracketPairs: [Character: Character] = [ @@ -67,6 +70,20 @@ final class EditorTextView: NSTextView { name: NSTextView.didChangeSelectionNotification, object: self ) + // Observe text changes to invalidate line cache + NotificationCenter.default.addObserver( + self, + selector: #selector(textDidChange(_:)), + name: NSText.didChangeNotification, + object: self + ) + } + + @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 } deinit { @@ -140,13 +157,13 @@ final class EditorTextView: NSTextView { // Invalidate the previous line rect if lastCursorLine >= 0 { if let rect = lineRectForLine(lastCursorLine, layoutManager: layoutManager, textContainer: textContainer) { - setNeedsDisplay(rect.insetBy(dx: -2, dy: -2)) + setNeedsDisplay(rect.insetBy(dx: -lineInvalidationMargin, dy: -lineInvalidationMargin)) } } // Invalidate the current line rect if let rect = lineRectForLine(currentLine, layoutManager: layoutManager, textContainer: textContainer) { - setNeedsDisplay(rect.insetBy(dx: -2, dy: -2)) + setNeedsDisplay(rect.insetBy(dx: -lineInvalidationMargin, dy: -lineInvalidationMargin)) } lastCursorLine = currentLine @@ -178,16 +195,20 @@ final class EditorTextView: NSTextView { var searchRange = NSRange(location: 0, length: text.length) var startLine = 0 - // Use cache if we're looking for a nearby line (common case: prev/current line) - if let cache = lineCache, abs(cache.lastLine - lineNumber) <= 1 { + // Use cache if we're looking for a nearby line AND cache is still valid for current text + if let cache = lineCache, + cache.searchRange.location < text.length, + NSMaxRange(cache.searchRange) <= text.length, + abs(cache.lastLine - lineNumber) <= 1 { + if cache.lastLine == lineNumber { - // Exact cache hit - charIndex = cache.charIndex + // Exact cache hit - use cached position + charIndex = min(cache.charIndex, text.length - 1) searchRange = cache.searchRange startLine = lineNumber } else if cache.lastLine + 1 == lineNumber { - // Next line after cached (common: moving from prev to current) - charIndex = cache.charIndex + // Start iteration from cached line to reach the next line + charIndex = min(cache.charIndex, text.length - 1) searchRange = cache.searchRange startLine = cache.lastLine } @@ -210,8 +231,12 @@ final class EditorTextView: NSTextView { charIndex = searchRange.location } - // Cache the result for next lookup - lineCache = (lineNumber, charIndex, searchRange) + // Only cache if the result is valid + if charIndex < text.length && searchRange.location <= text.length { + lineCache = (lineNumber, charIndex, searchRange) + } else { + lineCache = nil + } // If we reached the target line, charIndex is already set to its start // Otherwise it was clamped to the last valid position