Skip to content

Commit 7b18241

Browse files
authored
fix: context menu Format SQL not working (#659) (#662)
* fix: context menu Format SQL not working due to stale closure capture (#659) Root cause: formatQuery() passed as onFormatSQL closure captures QueryEditorView struct by value. After SwiftUI recreates the view, the captured @binding queryText becomes stale (empty). Toolbar button works because it runs in current render context. Fix: add fallback in context menu that formats directly via the text view when the callback closure is stale. Also lazily recreate context menu after destroy(). * fix: root cause fix for stale editor callbacks + 3 formatter bugs (#659) Coordinator lifecycle: - Move callback wiring from .onAppear to view body (fresh on every re-render) - Add revive() method to re-register with EditorEventRouter after tab switch - Add onFormatSQL = nil to destroy() cleanup - Add databaseType property to coordinator - Remove hacky formatSQLInTextView fallback (was hardcoding .mysql dialect) SQL formatter: - Fix comment placeholder order: single-pass extraction by source position - Fix SQL-standard '' escape not handled in string literal regex - Fix addLineBreaks ignoring uppercaseKeywords option
1 parent 569d962 commit 7b18241

3 files changed

Lines changed: 51 additions & 52 deletions

File tree

TablePro/Core/Services/Formatting/SQLFormatterService.swift

Lines changed: 17 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -40,26 +40,17 @@ struct SQLFormatterService: SQLFormatterProtocol {
4040
// MARK: - Cached Regex Patterns (CPU-3, CPU-9, CPU-10)
4141

4242
/// String literal extraction patterns — one per quote character
43+
/// Handles both backslash escapes (\'') and SQL-standard doubled-quote escapes ('')
4344
private static let stringLiteralRegexes: [String: NSRegularExpression] = {
4445
var result: [String: NSRegularExpression] = [:]
4546
for quoteChar in ["'", "\"", "`"] {
4647
let escaped = NSRegularExpression.escapedPattern(for: quoteChar)
47-
let pattern = "\(escaped)((?:\\\\\\\\\(quoteChar)|[^\(quoteChar)])*?)\(escaped)"
48+
let pattern = "\(escaped)((?:\\\\\\\\\(quoteChar)|\(escaped)\(escaped)|[^\(quoteChar)])*)\(escaped)"
4849
result[quoteChar] = regex(pattern)
4950
}
5051
return result
5152
}()
5253

53-
/// Line comment pattern: --[^\n]*
54-
private static let lineCommentRegex: NSRegularExpression = {
55-
regex("--[^\\n]*")
56-
}()
57-
58-
/// Block comment pattern: /* ... */
59-
private static let blockCommentRegex: NSRegularExpression = {
60-
regex("/\\*.*?\\*/", options: .dotMatchesLineSeparators)
61-
}()
62-
6354
/// Line break keyword patterns — pre-compiled for all 16 keywords (CPU-9)
6455
/// Sorted by keyword length (longest first) to handle multi-word keywords correctly
6556
private static let lineBreakRegexes: [(keyword: String, regex: NSRegularExpression)] = {
@@ -207,7 +198,7 @@ struct SQLFormatterService: SQLFormatterProtocol {
207198
result = restoreStringLiterals(result, literals: stringLiterals)
208199

209200
// Step 5: Add line breaks before major keywords
210-
result = addLineBreaks(result)
201+
result = addLineBreaks(result, options: options)
211202

212203
// Step 6: Add indentation based on nesting
213204
if options.indentSize > 0 {
@@ -288,38 +279,28 @@ struct SQLFormatterService: SQLFormatterProtocol {
288279

289280
// MARK: - Comment Handling (Fix #6: UUID placeholders)
290281

291-
/// Extract comments with UUID-based placeholders (prevents collisions)
282+
/// Combined pattern matching both line comments (--...) and block comments (/*...*/)
283+
private static let combinedCommentRegex: NSRegularExpression = {
284+
regex("--[^\\n]*|/\\*.*?\\*/", options: .dotMatchesLineSeparators)
285+
}()
286+
287+
/// Extract all comments in a single pass, ordered by position in the source SQL.
288+
/// This ensures __COMMENT_0__ is always the first comment, __COMMENT_1__ the second, etc.
292289
private func extractComments(from sql: String) -> (String, [(placeholder: String, content: String)]) {
293290
var result = sql
294291
var comments: [(String, String)] = []
295-
var counter = 0
296292

297-
// Extract line comments (-- ...) using cached regex
298-
let lineMatches = Self.lineCommentRegex.matches(
293+
let allMatches = Self.combinedCommentRegex.matches(
299294
in: result,
300295
range: NSRange(result.startIndex..., in: result)
301296
)
302-
for match in lineMatches.reversed() {
303-
if let range = safeRange(from: match.range, in: result) {
304-
let comment = String(result[range])
305-
let placeholder = "__COMMENT_\(counter)__"
306-
counter += 1
307-
comments.insert((placeholder, comment), at: 0)
308-
result.replaceSubrange(range, with: placeholder)
309-
}
310-
}
311297

312-
// Extract block comments (/* ... */) using cached regex
313-
// Note: This doesn't handle nested block comments (SQL doesn't officially support them)
314-
let blockMatches = Self.blockCommentRegex.matches(
315-
in: result,
316-
range: NSRange(result.startIndex..., in: result)
317-
)
318-
for match in blockMatches.reversed() {
298+
// Process in reverse to maintain valid indices; assign counters by source position
299+
for (reverseIndex, match) in allMatches.reversed().enumerated() {
319300
if let range = safeRange(from: match.range, in: result) {
320301
let comment = String(result[range])
302+
let counter = allMatches.count - 1 - reverseIndex
321303
let placeholder = "__COMMENT_\(counter)__"
322-
counter += 1
323304
comments.insert((placeholder, comment), at: 0)
324305
result.replaceSubrange(range, with: placeholder)
325306
}
@@ -363,15 +344,16 @@ struct SQLFormatterService: SQLFormatterProtocol {
363344

364345
// MARK: - Line Breaks
365346

366-
private func addLineBreaks(_ sql: String) -> String {
347+
private func addLineBreaks(_ sql: String, options: SQLFormatterOptions) -> String {
367348
var result = sql
368349

369350
// Use pre-compiled regex patterns for all line break keywords (CPU-9)
370351
for (keyword, regex) in Self.lineBreakRegexes {
352+
let replacement = options.uppercaseKeywords ? keyword.uppercased() : keyword
371353
result = regex.stringByReplacingMatches(
372354
in: result,
373355
range: NSRange(result.startIndex..., in: result),
374-
withTemplate: "\n\(keyword.uppercased())"
356+
withTemplate: "\n\(replacement)"
375357
)
376358
}
377359

TablePro/Views/Editor/SQLEditorCoordinator.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ final class SQLEditorCoordinator: TextViewCoordinator {
5050
@ObservationIgnored var onAIOptimize: ((String) -> Void)?
5151
@ObservationIgnored var onSaveAsFavorite: ((String) -> Void)?
5252
@ObservationIgnored var onFormatSQL: (() -> Void)?
53+
@ObservationIgnored var databaseType: DatabaseType?
5354

5455
/// Whether the editor text view is currently the first responder.
5556
/// Used to guard cursor propagation — when the find panel highlights
@@ -172,6 +173,7 @@ final class SQLEditorCoordinator: TextViewCoordinator {
172173
onAIExplain = nil
173174
onAIOptimize = nil
174175
onSaveAsFavorite = nil
176+
onFormatSQL = nil
175177
schemaProvider = nil
176178
contextMenu = nil
177179
vimEngine = nil
@@ -185,6 +187,17 @@ final class SQLEditorCoordinator: TextViewCoordinator {
185187
cleanupMonitors()
186188
}
187189

190+
func revive() {
191+
guard didDestroy else { return }
192+
didDestroy = false
193+
if let controller, let textView = controller.textView {
194+
EditorEventRouter.shared.register(self, textView: textView)
195+
}
196+
if contextMenu == nil, let controller {
197+
installAIContextMenu(controller: controller)
198+
}
199+
}
200+
188201
// MARK: - AI Context Menu
189202

190203
private func installAIContextMenu(controller: TextViewController) {
@@ -212,6 +225,9 @@ final class SQLEditorCoordinator: TextViewCoordinator {
212225

213226
/// Called by EditorEventRouter when a right-click is detected in this editor's text view.
214227
func showContextMenu(for event: NSEvent, in textView: TextView) {
228+
if contextMenu == nil, let controller {
229+
installAIContextMenu(controller: controller)
230+
}
215231
guard let menu = contextMenu else { return }
216232
NSMenu.popUpContextMenu(menu, with: event, for: textView)
217233
}

TablePro/Views/Editor/SQLEditorView.swift

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,18 @@ struct SQLEditorView: View {
3838
@Environment(\.colorScheme) private var colorScheme
3939

4040
var body: some View {
41-
Group {
41+
// Keep callbacks fresh on every parent re-render
42+
coordinator.onCloseTab = onCloseTab
43+
coordinator.onExecuteQuery = onExecuteQuery
44+
coordinator.onAIExplain = onAIExplain
45+
coordinator.onAIOptimize = onAIOptimize
46+
coordinator.onSaveAsFavorite = onSaveAsFavorite
47+
coordinator.onFormatSQL = onFormatSQL
48+
coordinator.schemaProvider = schemaProvider
49+
coordinator.connectionAIPolicy = connectionAIPolicy
50+
coordinator.databaseType = databaseType
51+
52+
return Group {
4253
if editorReady {
4354
SourceEditor(
4455
$text,
@@ -97,33 +108,23 @@ struct SQLEditorView: View {
97108
editorConfiguration = Self.makeConfiguration()
98109
}
99110
.onAppear {
111+
if coordinator.isDestroyed {
112+
coordinator.revive()
113+
}
100114
if completionAdapter == nil {
101115
completionAdapter = SQLCompletionAdapter(schemaProvider: schemaProvider, databaseType: databaseType)
102116
}
103-
coordinator.schemaProvider = schemaProvider
104-
coordinator.connectionAIPolicy = connectionAIPolicy
105-
coordinator.onCloseTab = onCloseTab
106-
coordinator.onExecuteQuery = onExecuteQuery
107-
coordinator.onAIExplain = onAIExplain
108-
coordinator.onAIOptimize = onAIOptimize
109-
coordinator.onSaveAsFavorite = onSaveAsFavorite
110-
coordinator.onFormatSQL = onFormatSQL
111117
setupFavoritesObserver()
112118
}
113119
} else {
114120
Color(nsColor: .textBackgroundColor)
115121
.onAppear {
122+
if coordinator.isDestroyed {
123+
coordinator.revive()
124+
}
116125
if completionAdapter == nil {
117126
completionAdapter = SQLCompletionAdapter(schemaProvider: schemaProvider, databaseType: databaseType)
118127
}
119-
coordinator.schemaProvider = schemaProvider
120-
coordinator.connectionAIPolicy = connectionAIPolicy
121-
coordinator.onCloseTab = onCloseTab
122-
coordinator.onExecuteQuery = onExecuteQuery
123-
coordinator.onAIExplain = onAIExplain
124-
coordinator.onAIOptimize = onAIOptimize
125-
coordinator.onSaveAsFavorite = onSaveAsFavorite
126-
coordinator.onFormatSQL = onFormatSQL
127128
setupFavoritesObserver()
128129
editorReady = true
129130
}

0 commit comments

Comments
 (0)