fix(editor): refresh SQL snippet completion after settings changes

This commit is contained in:
t8y2 2026-05-27 17:43:05 +08:00
parent aaa6cb177c
commit 915728ebec
4 changed files with 111 additions and 14 deletions

View File

@ -145,12 +145,15 @@ let codeMirrorTheme: import("@codemirror/state").Compartment | null = null;
let wordWrapComp: import("@codemirror/state").Compartment | null = null;
let readOnlyComp: import("@codemirror/state").Compartment | null = null;
let runKeymapComp: import("@codemirror/state").Compartment | null = null;
let completionComp: import("@codemirror/state").Compartment | null = null;
let diagnosticComp: import("@codemirror/state").Compartment | null = null;
let buildSqlDiagnosticExtension: (() => import("@codemirror/state").Extension) | null = null;
let buildSqlSignatureExtension: (() => import("@codemirror/state").Extension) | null = null;
let buildSqlCompletionExtension: (() => import("@codemirror/state").Extension) | null = null;
let codeMirrorSnippetCompletion: typeof import("@codemirror/autocomplete").snippetCompletion;
let codeMirrorCompletionStatus: typeof import("@codemirror/autocomplete").completionStatus | null = null;
let codeMirrorAcceptCompletion: typeof import("@codemirror/autocomplete").acceptCompletion | null = null;
let codeMirrorStartCompletion: typeof import("@codemirror/autocomplete").startCompletion | null = null;
let codeMirrorIndentMore: typeof import("@codemirror/commands").indentMore | null = null;
let codeMirrorInsertNewlineKeepIndent: typeof import("@codemirror/commands").insertNewlineKeepIndent | null = null;
let setSqlDiagnosticsEffect: import("@codemirror/state").StateEffectType<SqlSemanticDiagnostic[]> | null = null;
@ -698,7 +701,8 @@ async function provideSqlCompletions(
position: number,
explicit: boolean,
) {
if (!props.connectionId || props.database == null) return null;
if (!props.connectionId) return null;
const hasDatabase = props.database != null;
const epoch = ++completionEpoch;
@ -708,6 +712,37 @@ async function provideSqlCompletions(
const completionContext = getSqlCompletionContext(fullDoc, position);
if (!hasDatabase) {
const items = buildSqlCompletionItemsFromContext(completionContext, {
tables: [],
columnsByTable: new Map(),
schemas: [],
translations: completionTranslations.value,
snippets: settingsStore.editorSettings.snippets,
});
if (items.length === 0) return null;
return {
from: position - completionContext.prefix.length,
filter: false,
options: items.map((item) =>
(item.type === "snippet" || item.type === "function") && item.apply
? codeMirrorSnippetCompletion(item.apply, {
label: item.label,
type: item.type,
detail: item.detail,
boost: item.boost,
})
: {
label: item.label,
type: item.type,
detail: item.detail,
boost: item.boost,
},
),
validFor: getSqlCompletionResultValidFor(fullDoc, position),
};
}
// Handle INSERT column list: fetch columns for the target table
let insertColumnsByTable = new Map<string, SqlCompletionColumn[]>();
if (completionContext.insertTable) {
@ -889,8 +924,9 @@ async function provideSqlCompletions(
return {
from: position - completionContext.prefix.length,
filter: false,
options: items.map((item) =>
item.type === "snippet" && item.apply
(item.type === "snippet" || item.type === "function") && item.apply
? codeMirrorSnippetCompletion(item.apply, {
label: item.label,
type: item.type,
@ -968,10 +1004,12 @@ onMounted(async () => {
wordWrapComp = new Compartment();
readOnlyComp = new Compartment();
runKeymapComp = new Compartment();
completionComp = new Compartment();
diagnosticComp = new Compartment();
setSqlDiagnosticsEffect = StateEffect.define<SqlSemanticDiagnostic[]>();
codeMirrorCompletionStatus = completionStatus;
codeMirrorAcceptCompletion = acceptCompletion;
codeMirrorStartCompletion = startCompletion;
codeMirrorIndentMore = indentMore;
codeMirrorInsertNewlineKeepIndent = insertNewlineKeepIndent;
@ -1029,6 +1067,14 @@ onMounted(async () => {
};
});
buildSqlCompletionExtension = () =>
autocompletion({
activateOnTyping: true,
override: [
async (context: CompletionContext) => provideSqlCompletions(context.state, context.pos, context.explicit),
],
});
const ss = settingsStore.editorSettings;
const baseDialect = props.dialect === "postgres" ? PostgreSQL : props.dialect === "sqlserver" ? MSSQL : MySQL;
@ -1095,12 +1141,7 @@ onMounted(async () => {
keymap.of([...defaultKeymap, ...searchKeymap, ...historyKeymap, ...foldKeymap, ...completionKeymap]),
sql({ dialect }),
tooltips({ parent: document.body }),
autocompletion({
activateOnTyping: true,
override: [
async (context: CompletionContext) => provideSqlCompletions(context.state, context.pos, context.explicit),
],
}),
completionComp.of(buildSqlCompletionExtension()),
sqlCompletionTheme(EditorView),
codeMirrorTheme.of(theme),
closeBrackets(),
@ -1373,6 +1414,21 @@ watch(
{ deep: true },
);
watch(
() => settingsStore.editorSettings.snippets,
() => {
completionEpoch++;
if (!view.value || !completionComp || !buildSqlCompletionExtension) return;
view.value.dispatch({
effects: completionComp.reconfigure(buildSqlCompletionExtension()),
});
if (codeMirrorCompletionStatus?.(view.value.state) === "active") {
codeMirrorStartCompletion?.(view.value);
}
},
{ deep: true },
);
onBeforeUnmount(() => {
zoomCommitScheduler.dispose();
if (semanticDiagnosticTimer) clearTimeout(semanticDiagnosticTimer);

View File

@ -21,6 +21,21 @@ describe("buildSnippetItems", () => {
expect(items[0].label).toBe("select all");
});
it("does not keep matching a renamed snippet by its old short label prefix", () => {
const items = buildSnippetItemsForTest("sel", [
{ id: "1", label: "select all", prefix: "fff", body: "SELECT *\nFROM my_table;" },
]);
expect(items).toEqual([]);
});
it("still matches a renamed snippet by label when typing a longer descriptive query", () => {
const items = buildSnippetItemsForTest("select", [
{ id: "1", label: "select all", prefix: "fff", body: "SELECT *\nFROM my_table;" },
]);
expect(items).toHaveLength(1);
expect(items[0].label).toBe("select all");
});
it("returns empty for no match", () => {
const items = buildSnippetItemsForTest("zzz", TEST_SNIPPETS);
expect(items).toEqual([]);

View File

@ -610,7 +610,7 @@ export interface SqlCompletionColumn {
export interface SqlCompletionItem {
label: string;
type: "keyword" | "table" | "column" | "snippet" | "schema";
type: "keyword" | "table" | "column" | "snippet" | "function" | "schema";
detail?: string;
apply?: string;
boost: number;
@ -1934,16 +1934,20 @@ export function buildSnippetItemsForTest(prefix: string, snippets: SqlSnippet[])
function buildSnippetItems(prefix: string, snippets: SqlSnippet[]): SqlCompletionItem[] {
if (!prefix) return [];
return snippets
.filter((snippet) => matchesPrefix(snippet.prefix, prefix) || matchesPrefix(snippet.label, prefix))
.filter((snippet) => {
const matchesSnippetPrefix = matchesPrefix(snippet.prefix, prefix);
const matchesSnippetLabel = prefix.length > snippet.prefix.length && matchesPrefix(snippet.label, prefix);
return matchesSnippetPrefix || matchesSnippetLabel;
})
.map((snippet) => {
const boostByPrefix = computeBoost(snippet.prefix, prefix);
const boostByLabel = computeBoost(snippet.label, prefix);
return {
label: snippet.label,
type: "snippet" as const,
detail: snippet.label,
detail: snippet.body,
apply: snippet.body,
boost: Math.max(boostByPrefix, boostByLabel) - 1100,
boost: Math.max(boostByPrefix, boostByLabel) + 4000,
};
});
}
@ -1956,7 +1960,7 @@ function buildFunctionSnippetItems(prefix: string, functionDescriptions: Map<str
const paramStr = parameters.length > 0 ? parameters.map((p) => `\${${p}}`).join(", ") : "";
items.push({
label: name,
type: "snippet" as const,
type: "function" as const,
detail: functionDescriptions.get(name) ?? "function",
apply: `${name}(${paramStr})`,
boost: computeBoost(name, prefix) + 300,
@ -1968,7 +1972,7 @@ function buildFunctionSnippetItems(prefix: string, functionDescriptions: Map<str
if (!matchesPrefix(name, prefix)) continue;
items.push({
label: name,
type: "snippet" as const,
type: "function" as const,
detail: "window function",
apply: `${name}() OVER (PARTITION BY \${col} ORDER BY \${col})`,
boost: computeBoost(name, prefix) + 250,

View File

@ -0,0 +1,22 @@
import { strict as assert } from "node:assert";
import { readFileSync } from "node:fs";
import test from "node:test";
const source = readFileSync("apps/desktop/src/components/editor/QueryEditor.vue", "utf8");
test("query editor reconfigures autocompletion when snippets change", () => {
assert.match(source, /let completionComp: import\("@codemirror\/state"\)\.Compartment \| null = null;/);
assert.match(
source,
/let buildSqlCompletionExtension: \(\(\) => import\("@codemirror\/state"\)\.Extension\) \| null = null;/,
);
assert.match(source, /completionComp = new Compartment\(\);/);
assert.match(source, /completionComp\.of\(buildSqlCompletionExtension\(\)\)/);
assert.match(source, /\(\) => settingsStore\.editorSettings\.snippets/);
assert.match(source, /completionComp\.reconfigure\(buildSqlCompletionExtension\(\)\)/);
assert.match(source, /codeMirrorStartCompletion\?\.\(view\.value\)/);
});
test("query editor disables CodeMirror label re-filtering for custom SQL completions", () => {
assert.match(source, /from: position - completionContext\.prefix\.length,\s*filter: false,\s*options:/s);
});