From f92368a03e1faba50c51ced78ec11366c4ddb0ff 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: Fri, 12 Jun 2026 11:06:41 +0700 Subject: [PATCH 1/2] fix(editor): format SQL scopes to selection when text is selected --- CHANGELOG.md | 1 + .../Formatting/FormatScopeResolver.swift | 35 +++++++++ .../Views/Editor/SQLEditorCoordinator.swift | 21 ++++-- .../Services/FormatScopeResolverTests.swift | 75 +++++++++++++++++++ docs/features/keyboard-shortcuts.mdx | 2 +- 5 files changed, 125 insertions(+), 9 deletions(-) create mode 100644 TablePro/Core/Services/Formatting/FormatScopeResolver.swift create mode 100644 TableProTests/Core/Services/FormatScopeResolverTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 278bd4080..982c41389 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Format Query can now be undone with Cmd+Z; the formatting is applied as a single editor edit instead of clearing the undo history. (#1645) +- Format Query now formats only the selected text when a selection is active, and the full query when nothing is selected. (#1656) - Sorting a query result no longer overwrites the SQL editor text or the contents of an opened `.sql` file; the sort runs as a separate query and the editor keeps what you wrote. (#1645) - iCloud Sync between the iPhone and Mac apps: the iOS app now uses the Production CloudKit environment, so a development build no longer syncs into a separate database the Mac never reads. - Exports no longer fail mid-table on servers that enforce a statement time limit; the export session disables the limit and restores it afterwards, the same way mysqldump does. (#1633) diff --git a/TablePro/Core/Services/Formatting/FormatScopeResolver.swift b/TablePro/Core/Services/Formatting/FormatScopeResolver.swift new file mode 100644 index 000000000..1f9a2c427 --- /dev/null +++ b/TablePro/Core/Services/Formatting/FormatScopeResolver.swift @@ -0,0 +1,35 @@ +// +// FormatScopeResolver.swift +// TablePro +// + +import Foundation + +internal enum FormatScopeResolver { + struct Scope: Equatable { + let range: NSRange + let sql: String + let cursorOffset: Int? + } + + static func resolve(fullText: String, selectedRange: NSRange) -> Scope { + let nsText = fullText as NSString + let fullRange = NSRange(location: 0, length: nsText.length) + let hasSelection = selectedRange.location != NSNotFound + && selectedRange.length > 0 + && NSIntersectionRange(selectedRange, fullRange).length == selectedRange.length + + guard hasSelection else { + let cursor = selectedRange.location == NSNotFound + ? 0 + : min(selectedRange.location, nsText.length) + return Scope(range: fullRange, sql: fullText, cursorOffset: cursor) + } + + return Scope( + range: selectedRange, + sql: nsText.substring(with: selectedRange), + cursorOffset: nil + ) + } +} diff --git a/TablePro/Views/Editor/SQLEditorCoordinator.swift b/TablePro/Views/Editor/SQLEditorCoordinator.swift index 844dac577..8b90d89a5 100644 --- a/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -251,23 +251,28 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { func performFormatSQL() { guard let textView = controller?.textView else { return } let dialect = databaseType ?? .mysql - let cursorLocation = textView.selectedRange().location - let cursorOffset = cursorLocation == NSNotFound ? 0 : cursorLocation let formatter = SQLFormatterService() + let scope = FormatScopeResolver.resolve( + fullText: textView.string, + selectedRange: textView.selectedRange() + ) do { let result = try formatter.format( - textView.string, + scope.sql, dialect: dialect, - cursorOffset: cursorOffset, + cursorOffset: scope.cursorOffset, options: .default ) - let fullRange = NSRange(location: 0, length: (textView.string as NSString).length) - textView.replaceCharacters(in: fullRange, with: result.formattedSQL) + textView.replaceCharacters(in: scope.range, with: result.formattedSQL) + let formattedLength = (result.formattedSQL as NSString).length + let caretLocation: Int if let newOffset = result.cursorOffset { - let clamped = min(newOffset, (result.formattedSQL as NSString).length) - controller?.setCursorPositions([CursorPosition(range: NSRange(location: clamped, length: 0))]) + caretLocation = scope.range.location + min(newOffset, formattedLength) + } else { + caretLocation = scope.range.location + formattedLength } + controller?.setCursorPositions([CursorPosition(range: NSRange(location: caretLocation, length: 0))]) } catch { Self.logger.error("SQL Formatting error: \(error.localizedDescription, privacy: .public)") } diff --git a/TableProTests/Core/Services/FormatScopeResolverTests.swift b/TableProTests/Core/Services/FormatScopeResolverTests.swift new file mode 100644 index 000000000..21a783b7a --- /dev/null +++ b/TableProTests/Core/Services/FormatScopeResolverTests.swift @@ -0,0 +1,75 @@ +// +// FormatScopeResolverTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +struct FormatScopeResolverTests { + private let text = "SELECT * FROM users;\nSELECT * FROM orders;" + + @Test("No selection resolves to the full document") + func noSelectionReturnsFullRange() { + let scope = FormatScopeResolver.resolve(fullText: text, selectedRange: NSRange(location: 0, length: 0)) + #expect(scope.range == NSRange(location: 0, length: (text as NSString).length)) + #expect(scope.sql == text) + #expect(scope.cursorOffset == 0) + } + + @Test("No selection keeps the cursor offset for caret mapping") + func noSelectionCursorMidDocument() { + let scope = FormatScopeResolver.resolve(fullText: text, selectedRange: NSRange(location: 10, length: 0)) + #expect(scope.range == NSRange(location: 0, length: (text as NSString).length)) + #expect(scope.cursorOffset == 10) + } + + @Test("A selection resolves to exactly the selected subrange") + func selectionReturnsSubrange() { + let selection = NSRange(location: 21, length: 21) + let scope = FormatScopeResolver.resolve(fullText: text, selectedRange: selection) + #expect(scope.range == selection) + #expect(scope.sql == "SELECT * FROM orders;") + #expect(scope.cursorOffset == nil) + } + + @Test("A selection covering the whole document behaves like a selection") + func selectionCoversWholeDocument() { + let fullRange = NSRange(location: 0, length: (text as NSString).length) + let scope = FormatScopeResolver.resolve(fullText: text, selectedRange: fullRange) + #expect(scope.range == fullRange) + #expect(scope.sql == text) + #expect(scope.cursorOffset == nil) + } + + @Test("NSNotFound selection is treated as no selection") + func notFoundLocationTreatedAsNoSelection() { + let scope = FormatScopeResolver.resolve( + fullText: text, + selectedRange: NSRange(location: NSNotFound, length: 0) + ) + #expect(scope.range == NSRange(location: 0, length: (text as NSString).length)) + #expect(scope.cursorOffset == 0) + } + + @Test("A selection extending past the document falls back to the full document") + func outOfBoundsSelectionFallsBack() { + let scope = FormatScopeResolver.resolve( + fullText: text, + selectedRange: NSRange(location: 30, length: 500) + ) + #expect(scope.range == NSRange(location: 0, length: (text as NSString).length)) + #expect(scope.cursorOffset == 30) + } + + @Test("Unicode text resolves ranges in UTF-16 units") + func unicodeRangesUseUTF16() { + let unicodeText = "SELECT '😀' AS emoji;\nSELECT 1;" + let nsText = unicodeText as NSString + let secondStatement = NSRange(location: nsText.length - 9, length: 9) + let scope = FormatScopeResolver.resolve(fullText: unicodeText, selectedRange: secondStatement) + #expect(scope.sql == "SELECT 1;") + #expect(scope.range == secondStatement) + } +} diff --git a/docs/features/keyboard-shortcuts.mdx b/docs/features/keyboard-shortcuts.mdx index eac357308..c56cc6fd1 100644 --- a/docs/features/keyboard-shortcuts.mdx +++ b/docs/features/keyboard-shortcuts.mdx @@ -40,7 +40,7 @@ TablePro is keyboard-driven. Most actions have shortcuts, and most menu shortcut | Execute all statements | `Cmd+Shift+Enter` | Run all statements in the editor. Shows parameter panel if parameters are detected | | Cancel query | `Cmd+.` | Stop the currently running query | | Explain query | `Option+Cmd+E` | Show execution plan for query at cursor | -| Format SQL | `Cmd+Shift+L` | Format SQL query | +| Format SQL | `Cmd+Shift+L` | Format the selected SQL, or the whole query when nothing is selected | | Save as Favorite | `Cmd+D` | Save the current query as a favorite | ### Text Editing From 14aac6bdfb5a1b1f9db0911e3e40ce88365eae1d 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: Fri, 12 Jun 2026 11:39:08 +0700 Subject: [PATCH 2/2] fix(editor): preserve selection boundary whitespace when formatting SQL --- .../Formatting/FormatScopeResolver.swift | 16 +++++++- .../Views/Editor/SQLEditorCoordinator.swift | 11 ++++-- .../Services/FormatScopeResolverTests.swift | 38 +++++++++++++++++++ 3 files changed, 59 insertions(+), 6 deletions(-) diff --git a/TablePro/Core/Services/Formatting/FormatScopeResolver.swift b/TablePro/Core/Services/Formatting/FormatScopeResolver.swift index 1f9a2c427..7a2d95e62 100644 --- a/TablePro/Core/Services/Formatting/FormatScopeResolver.swift +++ b/TablePro/Core/Services/Formatting/FormatScopeResolver.swift @@ -10,6 +10,7 @@ internal enum FormatScopeResolver { let range: NSRange let sql: String let cursorOffset: Int? + let isSelection: Bool } static func resolve(fullText: String, selectedRange: NSRange) -> Scope { @@ -23,13 +24,24 @@ internal enum FormatScopeResolver { let cursor = selectedRange.location == NSNotFound ? 0 : min(selectedRange.location, nsText.length) - return Scope(range: fullRange, sql: fullText, cursorOffset: cursor) + return Scope(range: fullRange, sql: fullText, cursorOffset: cursor, isSelection: false) } return Scope( range: selectedRange, sql: nsText.substring(with: selectedRange), - cursorOffset: nil + cursorOffset: nil, + isSelection: true ) } + + static func reapplyBoundaryWhitespace(from original: String, to formatted: String) -> String { + guard let firstNonWhitespace = original.firstIndex(where: { !$0.isWhitespace }), + let lastNonWhitespace = original.lastIndex(where: { !$0.isWhitespace }) + else { return formatted } + + let prefix = original[original.startIndex..