From 6650a4d655f789c773c5450f18cb4bdb7b4e2d92 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 2 Jul 2026 20:23:31 +0700 Subject: [PATCH] feat(editor): support ;; cursor marker in favorite keyword expansion --- CHANGELOG.md | 1 + .../Autocomplete/SQLCompletionInsertion.swift | 28 +++++++ .../Core/Autocomplete/SQLSnippetMarker.swift | 28 +++++++ .../Views/Editor/SQLCompletionAdapter.swift | 12 +-- .../Views/Sidebar/FavoriteEditDialog.swift | 9 +++ .../SQLCompletionInsertionTests.swift | 79 +++++++++++++++++++ .../SQLCompletionProviderTests.swift | 14 ++++ .../Autocomplete/SQLSnippetMarkerTests.swift | 69 ++++++++++++++++ docs/features/autocomplete.mdx | 2 +- docs/features/favorites.mdx | 14 ++++ 10 files changed, 246 insertions(+), 10 deletions(-) create mode 100644 TablePro/Core/Autocomplete/SQLCompletionInsertion.swift create mode 100644 TablePro/Core/Autocomplete/SQLSnippetMarker.swift create mode 100644 TableProTests/Core/Autocomplete/SQLCompletionInsertionTests.swift create mode 100644 TableProTests/Core/Autocomplete/SQLSnippetMarkerTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 21064fe33..586a2a9ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/TablePro/Core/Autocomplete/SQLCompletionInsertion.swift b/TablePro/Core/Autocomplete/SQLCompletionInsertion.swift new file mode 100644 index 000000000..c2983dcfe --- /dev/null +++ b/TablePro/Core/Autocomplete/SQLCompletionInsertion.swift @@ -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) + } +} diff --git a/TablePro/Core/Autocomplete/SQLSnippetMarker.swift b/TablePro/Core/Autocomplete/SQLSnippetMarker.swift new file mode 100644 index 000000000..1cd9723ee --- /dev/null +++ b/TablePro/Core/Autocomplete/SQLSnippetMarker.swift @@ -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) + } +} diff --git a/TablePro/Views/Editor/SQLCompletionAdapter.swift b/TablePro/Views/Editor/SQLCompletionAdapter.swift index d2ea89f71..64edc1189 100644 --- a/TablePro/Views/Editor/SQLCompletionAdapter.swift +++ b/TablePro/Views/Editor/SQLCompletionAdapter.swift @@ -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))]) } } diff --git a/TablePro/Views/Sidebar/FavoriteEditDialog.swift b/TablePro/Views/Sidebar/FavoriteEditDialog.swift index b39f10ca5..23a44c8e9 100644 --- a/TablePro/Views/Sidebar/FavoriteEditDialog.swift +++ b/TablePro/Views/Sidebar/FavoriteEditDialog.swift @@ -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 diff --git a/TableProTests/Core/Autocomplete/SQLCompletionInsertionTests.swift b/TableProTests/Core/Autocomplete/SQLCompletionInsertionTests.swift new file mode 100644 index 000000000..ef771028a --- /dev/null +++ b/TableProTests/Core/Autocomplete/SQLCompletionInsertionTests.swift @@ -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) + } +} diff --git a/TableProTests/Core/Autocomplete/SQLCompletionProviderTests.swift b/TableProTests/Core/Autocomplete/SQLCompletionProviderTests.swift index 2b6017f16..1444a453f 100644 --- a/TableProTests/Core/Autocomplete/SQLCompletionProviderTests.swift +++ b/TableProTests/Core/Autocomplete/SQLCompletionProviderTests.swift @@ -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") diff --git a/TableProTests/Core/Autocomplete/SQLSnippetMarkerTests.swift b/TableProTests/Core/Autocomplete/SQLSnippetMarkerTests.swift new file mode 100644 index 000000000..d38b2cda9 --- /dev/null +++ b/TableProTests/Core/Autocomplete/SQLSnippetMarkerTests.swift @@ -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) + } +} diff --git a/docs/features/autocomplete.mdx b/docs/features/autocomplete.mdx index 7d1de9eda..30bf57653 100644 --- a/docs/features/autocomplete.mdx +++ b/docs/features/autocomplete.mdx @@ -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 diff --git a/docs/features/favorites.mdx b/docs/features/favorites.mdx index 2e89a9d9b..22a56967c 100644 --- a/docs/features/favorites.mdx +++ b/docs/features/favorites.mdx @@ -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 */} @@ -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