From 87ef51418826a9af28d8c3f12562422066022b66 Mon Sep 17 00:00:00 2001 From: zipg Date: Thu, 30 Jul 2026 00:08:04 +0800 Subject: [PATCH] fix(editor): restore custom snippet trigger matching --- .../src/components/editor/QueryEditor.vue | 7 ++- .../editor/sqlCompletionPresentation.spec.ts | 46 +++++++++++++++++++ .../sql/sqlCompletion.snippet.spec.ts | 12 +++++ .../lib/editor/sqlCompletionPresentation.ts | 15 ++++++ apps/desktop/src/lib/sql/sqlCompletion.ts | 2 + 5 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/editor/sqlCompletionPresentation.spec.ts create mode 100644 apps/desktop/src/lib/editor/sqlCompletionPresentation.ts diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index fbbebf752d..e3321e6e5c 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -65,6 +65,7 @@ import { EDITOR_FONT_FAMILY_CSS_VAR, EDITOR_FONT_SIZE_CSS_VAR, loadEditorTheme, import { createStatementGutterMarkerDom, shouldShowStatementGutter } from "@/lib/editor/codemirrorStatementGutter"; import { createQueryEditorSearchKeymap } from "@/lib/editor/queryEditorSearchKeymap"; import { appendSqlCompletionSpace } from "@/lib/editor/sqlCompletionInsertion"; +import { completionLabelPresentation } from "@/lib/editor/sqlCompletionPresentation"; import { clampEditorFontSize, createEditorZoomCommitScheduler, fontSizeFromGestureScale, fontSizeFromWheelDelta } from "@/lib/editor/editorZoom"; import { normalizeShortcutSettings, shortcutToCodeMirrorKey } from "@/lib/editor/shortcutRegistry"; import { trimmedSelectionLayer } from "@/lib/editor/codemirrorTrimmedSelectionLayer"; @@ -2490,12 +2491,14 @@ function shouldInsertSqlCompletionSpace(): boolean { } function completionOptionForItem(item: QueryCompletionItem) { + const filterText = "filterText" in item && typeof item.filterText === "string" ? item.filterText : undefined; + const labelPresentation = completionLabelPresentation(item.label, filterText); const record = () => { recordCompletionSelection(item.label, item.type); }; if ((item.type === "snippet" || item.type === "function") && item.apply) { const completion = codeMirrorSnippetCompletion(item.apply, { - label: item.label, + ...labelPresentation, type: item.type, detail: item.detail, info: item.info, @@ -2520,7 +2523,7 @@ function completionOptionForItem(item: QueryCompletionItem) { }; } return { - label: item.label, + ...labelPresentation, type: item.type, detail: item.detail, info: item.info, diff --git a/apps/desktop/src/lib/__tests__/editor/sqlCompletionPresentation.spec.ts b/apps/desktop/src/lib/__tests__/editor/sqlCompletionPresentation.spec.ts new file mode 100644 index 0000000000..966213323b --- /dev/null +++ b/apps/desktop/src/lib/__tests__/editor/sqlCompletionPresentation.spec.ts @@ -0,0 +1,46 @@ +// @vitest-environment happy-dom + +import { acceptCompletion, autocompletion, completionStatus, currentCompletions, snippetCompletion, startCompletion } from "@codemirror/autocomplete"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { describe, expect, it } from "vitest"; +import { completionLabelPresentation } from "@/lib/editor/sqlCompletionPresentation"; + +describe("SQL completion label presentation", () => { + it("filters a custom snippet by trigger while preserving its display label", () => { + expect(completionLabelPresentation("SELECT *", "ssf")).toEqual({ + label: "ssf SELECT *", + displayLabel: "SELECT *", + sortText: "SELECT *", + }); + }); + + it("leaves ordinary completion labels unchanged", () => { + expect(completionLabelPresentation("SELECT")).toEqual({ label: "SELECT" }); + expect(completionLabelPresentation("select *", "sel")).toEqual({ label: "select *" }); + }); + + it("keeps a differently named snippet visible and applicable in CodeMirror", async () => { + const presentation = completionLabelPresentation("SELECT *", "ssf"); + const view = new EditorView({ + parent: document.createElement("div"), + state: EditorState.create({ + doc: "ssf", + selection: { anchor: 3 }, + extensions: [ + autocompletion({ + interactionDelay: 0, + override: [() => ({ from: 0, options: [snippetCompletion("SELECT * FROM", { ...presentation, type: "snippet" })] })], + }), + ], + }), + }); + + startCompletion(view); + await expect.poll(() => completionStatus(view.state)).toBe("active"); + expect(currentCompletions(view.state)[0]).toMatchObject({ label: "ssf SELECT *", displayLabel: "SELECT *" }); + expect(acceptCompletion(view)).toBe(true); + expect(view.state.doc.toString()).toBe("SELECT * FROM"); + view.destroy(); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/sql/sqlCompletion.snippet.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlCompletion.snippet.spec.ts index 41ba28c15b..bea9ab4867 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlCompletion.snippet.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlCompletion.snippet.spec.ts @@ -50,6 +50,18 @@ describe("buildSnippetItems", () => { expect(items[0].apply).toBe("SELECT *\nFROM my_table;"); }); + it("keeps a distinct trigger available to CodeMirror filtering", () => { + const items = buildSnippetItemsForTest("ssf", [{ id: "custom", label: "SELECT *", prefix: "ssf", body: "SELECT * FROM" }]); + + expect(items).toEqual([ + expect.objectContaining({ + label: "SELECT *", + filterText: "ssf", + type: "snippet", + }), + ]); + }); + it("uses CodeMirror placeholders for the built-in select snippet", () => { const items = buildSnippetItemsForTest("sel", [BUILTIN_SELECT]); diff --git a/apps/desktop/src/lib/editor/sqlCompletionPresentation.ts b/apps/desktop/src/lib/editor/sqlCompletionPresentation.ts new file mode 100644 index 0000000000..e0eb6722b2 --- /dev/null +++ b/apps/desktop/src/lib/editor/sqlCompletionPresentation.ts @@ -0,0 +1,15 @@ +export interface CompletionLabelPresentation { + label: string; + displayLabel?: string; + sortText?: string; +} + +export function completionLabelPresentation(label: string, filterText?: string): CompletionLabelPresentation { + const normalizedFilterText = filterText?.trim(); + if (!normalizedFilterText || label.toLowerCase().startsWith(normalizedFilterText.toLowerCase())) return { label }; + return { + label: `${normalizedFilterText} ${label}`, + displayLabel: label, + sortText: label, + }; +} diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index 84d52cb7ae..5fc56cafdc 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -1191,6 +1191,7 @@ export interface SqlCompletionForeignKey { export interface SqlCompletionItem { label: string; + filterText?: string; type: "keyword" | "table" | "column" | "snippet" | "function" | "schema"; detail?: string; info?: string; @@ -3971,6 +3972,7 @@ function buildSnippetItems(prefix: string, snippets: SqlSnippet[], keywordCase?: const apply = applyBuiltinSnippetKeywordCase(snippet, applyBuiltinSnippetPlaceholders(snippet, resolvedBody), keywordCase); return { label: snippet.label, + filterText: snippet.prefix, type: "snippet" as const, detail: body, apply,