Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
47 changes: 47 additions & 0 deletions TablePro/Core/Services/Formatting/FormatScopeResolver.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
//
// FormatScopeResolver.swift
// TablePro
//

import Foundation

internal enum FormatScopeResolver {
struct Scope: Equatable {
let range: NSRange
let sql: String
let cursorOffset: Int?
let isSelection: Bool
}

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, isSelection: false)
}

return Scope(
range: selectedRange,
sql: nsText.substring(with: selectedRange),
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..<firstNonWhitespace]
let suffix = original[original.index(after: lastNonWhitespace)...]
return prefix + formatted + suffix
}
}
24 changes: 16 additions & 8 deletions TablePro/Views/Editor/SQLEditorCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -251,23 +251,31 @@ 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)
let replacement = scope.isSelection
? FormatScopeResolver.reapplyBoundaryWhitespace(from: scope.sql, to: result.formattedSQL)
: result.formattedSQL
textView.replaceCharacters(in: scope.range, with: replacement)
let replacementLength = (replacement 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, replacementLength)
} else {
caretLocation = scope.range.location + replacementLength
}
controller?.setCursorPositions([CursorPosition(range: NSRange(location: caretLocation, length: 0))])
} catch {
Self.logger.error("SQL Formatting error: \(error.localizedDescription, privacy: .public)")
}
Expand Down
113 changes: 113 additions & 0 deletions TableProTests/Core/Services/FormatScopeResolverTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
//
// 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("Scope reports whether it came from a selection")
func scopeReportsSelectionOrigin() {
let noSelection = FormatScopeResolver.resolve(fullText: text, selectedRange: NSRange(location: 0, length: 0))
#expect(noSelection.isSelection == false)
let selection = FormatScopeResolver.resolve(fullText: text, selectedRange: NSRange(location: 0, length: 6))
#expect(selection.isSelection)
}

@Test("A selection ending in a newline keeps the newline after formatting")
func trailingNewlinePreserved() {
let spliced = FormatScopeResolver.reapplyBoundaryWhitespace(
from: "select * from users;\n",
to: "SELECT *\nFROM users;"
)
#expect(spliced == "SELECT *\nFROM users;\n")
}

@Test("Leading and trailing whitespace of the selection both survive formatting")
func leadingAndTrailingWhitespacePreserved() {
let spliced = FormatScopeResolver.reapplyBoundaryWhitespace(
from: "\n select 1; \n\n",
to: "SELECT 1;"
)
#expect(spliced == "\n SELECT 1; \n\n")
}

@Test("Whitespace-only original returns the formatted text unchanged")
func whitespaceOnlyOriginalReturnsFormatted() {
let spliced = FormatScopeResolver.reapplyBoundaryWhitespace(from: " \n ", to: "SELECT 1;")
#expect(spliced == "SELECT 1;")
}

@Test("Original without boundary whitespace returns the formatted text unchanged")
func noBoundaryWhitespaceReturnsFormatted() {
let spliced = FormatScopeResolver.reapplyBoundaryWhitespace(from: "select 1;", to: "SELECT 1;")
#expect(spliced == "SELECT 1;")
}

@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)
}
}
2 changes: 1 addition & 1 deletion docs/features/keyboard-shortcuts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading