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 @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- iOS: add data to a table from the Shortcuts app. Two new shortcuts, Add Row to Table and Add Rows to Table, pick a saved connection, database or schema, and table, then insert from JSON or CSV. They run without opening the app. (#1788)
- Refresh button in Settings > Plugins > Browse to reload the plugin list on demand. (#1799)
- SQL Favorites: put ;; in a saved query to set where the cursor lands after keyword expansion in the editor. (#1795)

### Fixed

Expand Down
28 changes: 28 additions & 0 deletions TablePro/Core/Autocomplete/SQLCompletionInsertion.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
//
// SQLCompletionInsertion.swift
// TablePro
//
// Accept-time resolution of what text a completion inserts and where the
// caret lands, in UTF-16 units relative to the insertion start. Favorites
// honor the SQLSnippetMarker cursor token, function completions ending in
// "()" park the caret between the parentheses, everything else places the
// caret after the inserted text.
//

import Foundation

enum SQLCompletionInsertion {
struct Resolution {
let text: String
let cursorOffset: Int
}

static func resolve(for item: SQLCompletionItem) -> Resolution {
if item.kind == .favorite, let expansion = SQLSnippetMarker.expand(item.insertText) {
return Resolution(text: expansion.text, cursorOffset: expansion.cursorOffset)
}
let length = (item.insertText as NSString).length
let cursorOffset = item.insertText.hasSuffix("()") ? length - 1 : length
return Resolution(text: item.insertText, cursorOffset: cursorOffset)
}
}
28 changes: 28 additions & 0 deletions TablePro/Core/Autocomplete/SQLSnippetMarker.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
//
// SQLSnippetMarker.swift
// TablePro
//
// Cursor-placement marker for SQL favorite keyword expansion. The first
// ";;" in a favorite's query marks where the caret lands after the keyword
// expands; the marker is stripped from the inserted text. Offsets are
// UTF-16 units so the caret resolves correctly after multibyte text.
//

import Foundation

enum SQLSnippetMarker {
static let token = ";;"

struct Expansion {
let text: String
let cursorOffset: Int
}

static func expand(_ raw: String) -> Expansion? {
let mutable = NSMutableString(string: raw)
let markerRange = mutable.range(of: token)
guard markerRange.location != NSNotFound else { return nil }
mutable.deleteCharacters(in: markerRange)
return Expansion(text: mutable as String, cursorOffset: markerRange.location)
}
}
12 changes: 3 additions & 9 deletions TablePro/Views/Editor/SQLCompletionAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -306,20 +306,14 @@ final class SQLCompletionAdapter: CodeSuggestionDelegate {
cursor: cursorPosition?.range.location,
fallback: context.replacementRange
)
let insertText = entry.item.insertText
let resolution = SQLCompletionInsertion.resolve(for: entry.item)

textView.textView.replaceCharacters(
in: [replaceRange],
with: insertText
with: resolution.text
)

let insertLength = (insertText as NSString).length
let newPosition: Int
if insertText.hasSuffix("()") {
newPosition = replaceRange.location + insertLength - 1
} else {
newPosition = replaceRange.location + insertLength
}
let newPosition = replaceRange.location + resolution.cursorOffset
textView.setCursorPositions([CursorPosition(range: NSRange(location: newPosition, length: 0))])
}
}
Expand Down
9 changes: 9 additions & 0 deletions TablePro/Views/Sidebar/FavoriteEditDialog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,15 @@ internal struct FavoriteEditDialog: View {
)
}

LabeledContent {} label: {
Text(String(
format: String(localized: "Type %@ in the query to set where the cursor lands after keyword expansion."),
SQLSnippetMarker.token
))
.font(.caption)
.foregroundStyle(.secondary)
}

TextField("Keyword", text: $keyword)
.focused($focusedField, equals: .keyword)
.onChange(of: keyword) { _, newValue in
Expand Down
79 changes: 79 additions & 0 deletions TableProTests/Core/Autocomplete/SQLCompletionInsertionTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
//
// SQLCompletionInsertionTests.swift
// TableProTests
//
// Tests for SQLCompletionInsertion: the accept-time decision for what text
// a completion inserts and where the caret lands. Pins the favorite marker
// rule, the function-paren rule, and the end-of-text default.
//

import Foundation
@testable import TablePro
import Testing

@Suite("SQLCompletionInsertion")
struct SQLCompletionInsertionTests {
@Test("Favorite with a marker inserts stripped text with the caret at the marker")
func favoriteWithMarker() {
let item = SQLCompletionItem.favorite(
keyword: "slc",
name: "Count rows",
query: "SELECT COUNT(*)\nFROM alias\nWHERE alias.;;"
)
let resolution = SQLCompletionInsertion.resolve(for: item)
#expect(resolution.text == "SELECT COUNT(*)\nFROM alias\nWHERE alias.")
#expect(resolution.cursorOffset == (resolution.text as NSString).length)
}

@Test("Favorite with a mid-query marker places the caret inside the text")
func favoriteWithMidQueryMarker() {
let item = SQLCompletionItem.favorite(
keyword: "cnt",
name: "Count where",
query: "SELECT COUNT(*) FROM t WHERE ;; LIMIT 1"
)
let resolution = SQLCompletionInsertion.resolve(for: item)
#expect(resolution.text == "SELECT COUNT(*) FROM t WHERE LIMIT 1")
#expect(resolution.cursorOffset == 29)
}

@Test("Favorite without a marker keeps the caret at the end")
func favoriteWithoutMarker() {
let item = SQLCompletionItem.favorite(keyword: "usr", name: "Users", query: "SELECT * FROM users")
let resolution = SQLCompletionInsertion.resolve(for: item)
#expect(resolution.text == "SELECT * FROM users")
#expect(resolution.cursorOffset == ("SELECT * FROM users" as NSString).length)
}

@Test("Function completion parks the caret between the parentheses")
func functionParenRule() {
let item = SQLCompletionItem.function("COUNT", signature: "COUNT(expr)")
let resolution = SQLCompletionInsertion.resolve(for: item)
#expect(resolution.text == "COUNT()")
#expect(resolution.cursorOffset == 6)
}

@Test("Markerless favorite ending in parentheses keeps the paren rule")
func favoriteEndingInParens() {
let item = SQLCompletionItem.favorite(keyword: "now", name: "Now", query: "SELECT NOW()")
let resolution = SQLCompletionInsertion.resolve(for: item)
#expect(resolution.text == "SELECT NOW()")
#expect(resolution.cursorOffset == ("SELECT NOW()" as NSString).length - 1)
}

@Test("Non-favorite item containing the marker keeps it literal")
func nonFavoriteKeepsLiteralMarker() {
let item = SQLCompletionItem(label: "BEGIN;;", kind: .keyword)
let resolution = SQLCompletionInsertion.resolve(for: item)
#expect(resolution.text == "BEGIN;;")
#expect(resolution.cursorOffset == ("BEGIN;;" as NSString).length)
}

@Test("Plain keyword places the caret after the inserted text")
func plainKeyword() {
let item = SQLCompletionItem.keyword("SELECT")
let resolution = SQLCompletionInsertion.resolve(for: item)
#expect(resolution.text == "SELECT")
#expect(resolution.cursorOffset == 6)
}
}
14 changes: 14 additions & 0 deletions TableProTests/Core/Autocomplete/SQLCompletionProviderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1112,6 +1112,20 @@ struct SQLCompletionProviderTests {
#expect(items.allSatisfy { $0.kind == .favorite })
}

@Test("Favorite items keep the raw cursor marker in insertText")
func testFavoriteKeepsRawCursorMarker() async {
provider.updateFavoriteKeywords([
"slc": (name: "Count", query: "SELECT COUNT(*) FROM t WHERE x = ;;")
])
let text = "slc"
let (items, _) = await provider.getCompletions(text: text, cursorPosition: text.count)
let favorite = items.first { $0.kind == .favorite }
#expect(
favorite?.insertText.contains(SQLSnippetMarker.token) == true,
"Marker stripping happens at accept time, not at item construction"
)
}

// MARK: - Tables after JOIN (#1646)

@Test("JOIN after an ON condition suggests available tables")
Expand Down
69 changes: 69 additions & 0 deletions TableProTests/Core/Autocomplete/SQLSnippetMarkerTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
//
// SQLSnippetMarkerTests.swift
// TableProTests
//
// Tests for SQLSnippetMarker: the ";;" cursor token stripped from a
// favorite's query on keyword expansion. Offsets must be UTF-16 units so a
// marker after multibyte text still lands the caret correctly.
//

import Foundation
@testable import TablePro
import Testing

@Suite("SQLSnippetMarker")
struct SQLSnippetMarkerTests {
@Test("Query without a marker expands to nil")
func noMarkerReturnsNil() {
#expect(SQLSnippetMarker.expand("SELECT * FROM users") == nil)
}

@Test("Empty query expands to nil")
func emptyQueryReturnsNil() {
#expect(SQLSnippetMarker.expand("") == nil)
}

@Test("Marker at the start strips and places the caret at zero")
func markerAtStart() {
let expansion = SQLSnippetMarker.expand(";;SELECT 1")
#expect(expansion?.text == "SELECT 1")
#expect(expansion?.cursorOffset == 0)
}

@Test("Marker in the middle strips and places the caret at its position")
func markerInMiddle() {
let expansion = SQLSnippetMarker.expand("SELECT ;; FROM users")
#expect(expansion?.text == "SELECT FROM users")
#expect(expansion?.cursorOffset == 7)
}

@Test("Marker at the end places the caret after the stripped text")
func markerAtEnd() {
let expansion = SQLSnippetMarker.expand("SELECT 1;;")
#expect(expansion?.text == "SELECT 1")
#expect(expansion?.cursorOffset == 8)
}

@Test("Only the first marker is stripped, later ones stay literal")
func firstMarkerWins() {
let expansion = SQLSnippetMarker.expand("WHERE a = ;; AND b = ;;")
#expect(expansion?.text == "WHERE a = AND b = ;;")
#expect(expansion?.cursorOffset == 10)
}

@Test("Marker offset counts UTF-16 units after multibyte text")
func markerAfterMultibyteText() {
let prefix = "-- ghi chú 🙂\nSELECT * FROM t WHERE x = "
let expansion = SQLSnippetMarker.expand(prefix + ";;")
#expect(expansion?.text == prefix)
#expect(expansion?.cursorOffset == (prefix as NSString).length)
}

@Test("The issue #1795 template resolves to the caret after the alias dot")
func issueTemplate() {
let expansion = SQLSnippetMarker.expand("SELECT COUNT(*)\nFROM alias\nWHERE alias.;;")
let expected = "SELECT COUNT(*)\nFROM alias\nWHERE alias."
#expect(expansion?.text == expected)
#expect(expansion?.cursorOffset == (expected as NSString).length)
}
}
2 changes: 1 addition & 1 deletion docs/features/autocomplete.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ WHERE date_column > | -- NOW(), CURRENT_DATE, etc.

### Favorite Keywords

Favorites you've assigned a keyword to (DB-stored or linked-file `@keyword` frontmatter) appear in the popup as a top-priority match. Type the keyword, accept the suggestion, and the favorite's full SQL replaces the keyword inline. See [Favorites](/features/favorites) for how to assign keywords.
Favorites you've assigned a keyword to (DB-stored or linked-file `@keyword` frontmatter) appear in the popup as a top-priority match. Type the keyword, accept the suggestion, and the favorite's full SQL replaces the keyword inline. A `;;` in the favorite's SQL sets where the cursor lands after expansion. See [Favorites](/features/favorites#cursor-placement) for how to assign keywords and place the marker.

### Schema Names

Expand Down
14 changes: 14 additions & 0 deletions docs/features/favorites.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,18 @@ Assign a unique keyword to a favorite (e.g., `selall`). Start typing the keyword

Keywords must be unique across all favorites in the same scope. Typing a keyword in the Quick Switcher (`Shift+Cmd+O`) also finds the saved query.

### Cursor Placement

Put `;;` in a favorite's SQL to set where the cursor lands after expansion. The marker is removed on insert and the cursor is placed at its position:

```sql
SELECT COUNT(*)
FROM orders
WHERE orders.;;
```

Accepting the keyword inserts the query without the `;;` and leaves the cursor right after `orders.`, ready for a column name. Only the first `;;` counts as a marker. Without one, the cursor lands at the end of the inserted SQL.

A favorite saved with a connection scope belongs to that connection: it appears only there, and deleting the connection deletes its saved queries. Use the **Global** scope for queries you want on every connection.

{/* Screenshot: Keyword expansion in autocomplete */}
Expand Down Expand Up @@ -132,6 +144,8 @@ WHERE last_seen > NOW() - INTERVAL 24 HOUR;

The parser stops at the first non-frontmatter line, so put these at the very top of the file. UTF-8 BOM at the start of the file is handled. Files without frontmatter still appear, with the filename as the display name and no keyword registered.

The `;;` [cursor marker](#cursor-placement) works in linked files too: a `@keyword` expansion places the cursor at the first `;;` in the file's SQL.

To edit frontmatter without opening the file, right-click a linked row and choose **Edit Metadata...** The dialog rewrites only the leading comment block and preserves the rest of the file plus its original encoding.

### Drag and drop
Expand Down
Loading