fix(editor): improve SQL completion insertion

This commit is contained in:
t8y2 2026-07-26 13:10:02 +08:00
parent b5435e7795
commit 931cff48c0
No known key found for this signature in database
11 changed files with 101 additions and 2 deletions

View File

@ -283,6 +283,7 @@ const editShowStatementRunButtons = ref(settingsStore.editorSettings.showStateme
const editShowCurrentStatementFrame = ref(settingsStore.editorSettings.showCurrentStatementFrame);
const editShowInsertValueHints = ref(settingsStore.editorSettings.showInsertValueHints);
const editAutoAliasTables = ref(settingsStore.editorSettings.autoAliasTables);
const editInsertSpaceAfterCompletion = ref(settingsStore.editorSettings.insertSpaceAfterCompletion);
const editWordWrap = ref(settingsStore.editorSettings.wordWrap);
const editVimModeEnabled = ref(settingsStore.editorSettings.vimModeEnabled);
const editAutoCloseBrackets = ref(settingsStore.editorSettings.autoCloseBrackets);
@ -438,6 +439,7 @@ function currentEditorSettingsDraft(): EditorSettingsDraft {
showCurrentStatementFrame: editShowCurrentStatementFrame.value,
showInsertValueHints: editShowInsertValueHints.value,
autoAliasTables: editAutoAliasTables.value,
insertSpaceAfterCompletion: editInsertSpaceAfterCompletion.value,
wordWrap: editWordWrap.value,
vimModeEnabled: editVimModeEnabled.value,
autoCloseBrackets: editAutoCloseBrackets.value,
@ -689,6 +691,7 @@ function syncEditorSettingsDraftFromStore() {
editShowCurrentStatementFrame.value = settingsStore.editorSettings.showCurrentStatementFrame;
editShowInsertValueHints.value = settingsStore.editorSettings.showInsertValueHints;
editAutoAliasTables.value = settingsStore.editorSettings.autoAliasTables;
editInsertSpaceAfterCompletion.value = settingsStore.editorSettings.insertSpaceAfterCompletion;
editWordWrap.value = settingsStore.editorSettings.wordWrap;
editVimModeEnabled.value = settingsStore.editorSettings.vimModeEnabled;
editAutoCloseBrackets.value = settingsStore.editorSettings.autoCloseBrackets;
@ -884,6 +887,7 @@ function resetDefaultsForTab(tab: SettingsCategory) {
editShowCurrentStatementFrame.value = DEFAULT_EDITOR_SETTINGS.showCurrentStatementFrame;
editShowInsertValueHints.value = DEFAULT_EDITOR_SETTINGS.showInsertValueHints;
editAutoAliasTables.value = DEFAULT_EDITOR_SETTINGS.autoAliasTables;
editInsertSpaceAfterCompletion.value = DEFAULT_EDITOR_SETTINGS.insertSpaceAfterCompletion;
editWordWrap.value = DEFAULT_EDITOR_SETTINGS.wordWrap;
editVimModeEnabled.value = DEFAULT_EDITOR_SETTINGS.vimModeEnabled;
editAutoCloseBrackets.value = DEFAULT_EDITOR_SETTINGS.autoCloseBrackets;
@ -969,6 +973,7 @@ function resetAllDefaults() {
editShowCurrentStatementFrame.value = DEFAULT_EDITOR_SETTINGS.showCurrentStatementFrame;
editShowInsertValueHints.value = DEFAULT_EDITOR_SETTINGS.showInsertValueHints;
editAutoAliasTables.value = DEFAULT_EDITOR_SETTINGS.autoAliasTables;
editInsertSpaceAfterCompletion.value = DEFAULT_EDITOR_SETTINGS.insertSpaceAfterCompletion;
editWordWrap.value = DEFAULT_EDITOR_SETTINGS.wordWrap;
editVimModeEnabled.value = DEFAULT_EDITOR_SETTINGS.vimModeEnabled;
editAutoCloseBrackets.value = DEFAULT_EDITOR_SETTINGS.autoCloseBrackets;
@ -3304,6 +3309,14 @@ onUnmounted(cleanupPreviewEditor);
<Switch id="editor-auto-close-brackets" v-model="editAutoCloseBrackets" class="mt-0.5" />
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="editor-insert-space-after-completion">{{ t("settings.insertSpaceAfterCompletion") }}</Label>
<p class="text-xs text-muted-foreground">{{ t("settings.insertSpaceAfterCompletionDescription") }}</p>
</div>
<Switch id="editor-insert-space-after-completion" v-model="editInsertSpaceAfterCompletion" class="mt-0.5" />
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="editor-auto-alias-tables">{{ t("settings.autoAliasTables") }}</Label>

View File

@ -61,6 +61,7 @@ import {
import { EDITOR_FONT_FAMILY_CSS_VAR, EDITOR_FONT_SIZE_CSS_VAR, loadEditorTheme, editorFontTheme, sqlCompletionTheme, sqlSemanticHighlightTheme } from "@/lib/editor/editorThemes";
import { createStatementGutterMarkerDom, shouldShowStatementGutter } from "@/lib/editor/codemirrorStatementGutter";
import { createQueryEditorSearchKeymap } from "@/lib/editor/queryEditorSearchKeymap";
import { appendSqlCompletionSpace } from "@/lib/editor/sqlCompletionInsertion";
import { clampEditorFontSize, createEditorZoomCommitScheduler, fontSizeFromGestureScale, fontSizeFromWheelDelta } from "@/lib/editor/editorZoom";
import { normalizeShortcutSettings, shortcutToCodeMirrorKey } from "@/lib/editor/shortcutRegistry";
import { trimmedSelectionLayer } from "@/lib/editor/codemirrorTrimmedSelectionLayer";
@ -2284,7 +2285,7 @@ function buildCompletionResult(items: QueryCompletionItem[], from: number, valid
if (items.length === 0) return null;
return {
from,
filter: false,
// Keep CodeMirror's live filtering enabled so an already-open menu follows the typed prefix.
options: items.map((item) => completionOptionForItem(item)),
validFor,
};
@ -2307,6 +2308,10 @@ function localCompletionDatabaseNames(completionContext: ReturnType<typeof getSq
return connectionStore.lookupLocalCompletionDatabases(props.connectionId, completionContext.qualifier || completionContext.prefix, MAX_COMPLETION_TABLES);
}
function shouldInsertSqlCompletionSpace(): boolean {
return props.databaseType !== "mongodb" && props.databaseType !== "redis" && props.databaseType !== "elasticsearch";
}
function completionOptionForItem(item: QueryCompletionItem) {
const record = () => {
recordCompletionSelection(item.label, item.type);
@ -2346,7 +2351,11 @@ function completionOptionForItem(item: QueryCompletionItem) {
apply(view: EditorViewType, _completionItem: unknown, from: number, to: number) {
record();
markCompletionAccepted(item);
const insert = item.apply ?? item.label;
const insert = appendSqlCompletionSpace(item.apply ?? item.label, {
enabled: shouldInsertSqlCompletionSpace() && settingsStore.editorSettings.insertSpaceAfterCompletion,
itemType: item.type,
nextCharacter: view.state.sliceDoc(to, to + 1),
});
if (codeMirrorInsertCompletionText) {
view.dispatch(codeMirrorInsertCompletionText(view.state, insert, from, to));
} else {

View File

@ -4009,6 +4009,8 @@ export default {
vimModeDescription: "Use Vim-style modal editing in the SQL editor",
autoCloseBrackets: "Auto-close brackets",
autoCloseBracketsDescription: "Automatically insert closing brackets and quotes when typing an opening one",
insertSpaceAfterCompletion: "Insert a space after completion",
insertSpaceAfterCompletionDescription: "Append a space after accepting a keyword, table, or column completion when the next character allows it",
sqlSemanticDiagnosticsEnabled: "SQL semantic diagnostics",
sqlSemanticDiagnosticsEnabledDescription: "When enabled, the editor reports semantic issues such as unknown tables and columns. Disable it to reduce parsing and metadata checks for large SQL.",
confirmDangerousSqlExecution: "Confirm before dangerous SQL",

View File

@ -4008,6 +4008,8 @@ export default withEnglishFallback({
vimModeDescription: "在 SQL 编辑器中使用 Vim 风格的模态编辑",
autoCloseBrackets: "自动成对补全",
autoCloseBracketsDescription: "输入左括号或左引号时自动补全对应的右括号或右引号",
insertSpaceAfterCompletion: "补全后自动添加空格",
insertSpaceAfterCompletionDescription: "选择关键字、表名或列名补全后,在后续字符允许时自动追加空格",
sqlSemanticDiagnosticsEnabled: "SQL 语义诊断",
sqlSemanticDiagnosticsEnabledDescription: "开启后,编辑器会提示未知表、字段等语义问题;关闭可减少 SQL 解析和元数据检查的性能开销。",
confirmDangerousSqlExecution: "执行危险 SQL 前弹出确认",

View File

@ -89,6 +89,18 @@ afterEach(() => {
});
describe("QueryEditor completion Tab keymap", () => {
it("keeps CodeMirror prefix filtering enabled for SQL completion results", () => {
const javascript = ts.transpileModule(extractFunction("buildCompletionResult"), {
compilerOptions: { module: ts.ModuleKind.None, target: ts.ScriptTarget.ES2022 },
}).outputText;
const buildCompletionResult = new Function("completionOptionForItem", `${javascript}\nreturn buildCompletionResult;`)(() => ({ label: "created_at" })) as (items: Array<{ label: string }>, from: number) => { filter?: boolean; options: Array<{ label: string }>; from: number } | null;
const result = buildCompletionResult([{ label: "created_at" }], 0);
expect(result).not.toBeNull();
expect(result).not.toHaveProperty("filter");
});
it("keeps normal Tab indentation when completion is inactive", () => {
const harness = createHarness({ completionStatus: () => null });
const view = createView();

View File

@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { appendSqlCompletionSpace } from "@/lib/editor/sqlCompletionInsertion";
describe("SQL completion insertion", () => {
it("appends a space to ordinary completions by default", () => {
expect(appendSqlCompletionSpace("SELECT", { enabled: true, itemType: "keyword" })).toBe("SELECT ");
expect(appendSqlCompletionSpace("orders AS o", { enabled: true, itemType: "table" })).toBe("orders AS o ");
});
it("does not add duplicate or invalid spaces", () => {
expect(appendSqlCompletionSpace("SELECT ", { enabled: true, itemType: "keyword" })).toBe("SELECT ");
expect(appendSqlCompletionSpace("public.", { enabled: true, itemType: "schema" })).toBe("public.");
expect(appendSqlCompletionSpace("name", { enabled: true, itemType: "property" })).toBe("name");
expect(appendSqlCompletionSpace("key", { enabled: true, itemType: "text" })).toBe("key");
expect(appendSqlCompletionSpace("orders", { enabled: true, itemType: "table", nextCharacter: ")" })).toBe("orders");
expect(appendSqlCompletionSpace("orders", { enabled: true, itemType: "table", nextCharacter: "," })).toBe("orders");
});
it("honors the setting and leaves snippet-like completions unchanged", () => {
expect(appendSqlCompletionSpace("SELECT", { enabled: false, itemType: "keyword" })).toBe("SELECT");
expect(appendSqlCompletionSpace("CASE ${value}", { enabled: true, itemType: "snippet" })).toBe("CASE ${value}");
expect(appendSqlCompletionSpace("count()", { enabled: true, itemType: "function" })).toBe("count()");
});
});

View File

@ -0,0 +1,14 @@
export type SqlCompletionItemType = "keyword" | "table" | "column" | "snippet" | "function" | "schema" | "property" | "text";
const COMPLETION_SPACE_BLOCKING_CHARACTERS = new Set([",", ";", ":", ")", "]", "}", "'", '"']);
export function appendSqlCompletionSpace(insertText: string, options: { enabled: boolean; itemType: SqlCompletionItemType; nextCharacter?: string }): string {
if (!options.enabled || options.itemType === "property" || options.itemType === "text" || options.itemType === "schema" || options.itemType === "snippet" || options.itemType === "function") return insertText;
if (!insertText || /\s$/.test(insertText) || insertText.endsWith(".")) return insertText;
const nextCharacter = options.nextCharacter ?? "";
if (/\s/.test(nextCharacter) || COMPLETION_SPACE_BLOCKING_CHARACTERS.has(nextCharacter)) return insertText;
// Avoid producing `table AS t,` or `column)` when the cursor is before SQL punctuation.
return `${insertText} `;
}

View File

@ -16,6 +16,7 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [
"showCurrentStatementFrame",
"showInsertValueHints",
"autoAliasTables",
"insertSpaceAfterCompletion",
"wordWrap",
"vimModeEnabled",
"autoCloseBrackets",

View File

@ -34,6 +34,11 @@ describe("normalizeEditorSettings", () => {
expect(normalizeEditorSettings({ autoAliasTables: false }).autoAliasTables).toBe(false);
});
it("enables a trailing space after completion by default and preserves the opt-out", () => {
expect(normalizeEditorSettings({}).insertSpaceAfterCompletion).toBe(true);
expect(normalizeEditorSettings({ insertSpaceAfterCompletion: false }).insertSpaceAfterCompletion).toBe(false);
});
it("defaults sidebar connection sorting to manual order and preserves valid alphabetical modes", () => {
expect(normalizeEditorSettings({}).sidebarConnectionSortMode).toBe("manual");
expect(normalizeEditorSettings({ sidebarConnectionSortMode: "asc" }).sidebarConnectionSortMode).toBe("asc");

View File

@ -394,6 +394,7 @@ export interface EditorSettings {
showCurrentStatementFrame: boolean;
showInsertValueHints: boolean;
autoAliasTables: boolean;
insertSpaceAfterCompletion: boolean;
wordWrap: boolean;
vimModeEnabled: boolean;
autoCloseBrackets: boolean;
@ -557,6 +558,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = {
showCurrentStatementFrame: true,
showInsertValueHints: true,
autoAliasTables: true,
insertSpaceAfterCompletion: true,
wordWrap: false,
vimModeEnabled: false,
autoCloseBrackets: true,
@ -835,6 +837,7 @@ export function normalizeEditorSettings(settings: Partial<EditorSettings>, exist
showCurrentStatementFrame: typeof settings.showCurrentStatementFrame === "boolean" ? settings.showCurrentStatementFrame : DEFAULT_EDITOR_SETTINGS.showCurrentStatementFrame,
showInsertValueHints: typeof settings.showInsertValueHints === "boolean" ? settings.showInsertValueHints : DEFAULT_EDITOR_SETTINGS.showInsertValueHints,
autoAliasTables: settings.autoAliasTables ?? DEFAULT_EDITOR_SETTINGS.autoAliasTables,
insertSpaceAfterCompletion: typeof settings.insertSpaceAfterCompletion === "boolean" ? settings.insertSpaceAfterCompletion : DEFAULT_EDITOR_SETTINGS.insertSpaceAfterCompletion,
wordWrap: settings.wordWrap ?? DEFAULT_EDITOR_SETTINGS.wordWrap,
vimModeEnabled: typeof settings.vimModeEnabled === "boolean" ? settings.vimModeEnabled : DEFAULT_EDITOR_SETTINGS.vimModeEnabled,
autoCloseBrackets: typeof settings.autoCloseBrackets === "boolean" ? settings.autoCloseBrackets : DEFAULT_EDITOR_SETTINGS.autoCloseBrackets,
@ -1202,6 +1205,7 @@ export const useSettingsStore = defineStore("settings", () => {
if (partial.showCurrentStatementFrame !== undefined) editorSettings.value.showCurrentStatementFrame = partial.showCurrentStatementFrame === true;
if (partial.showInsertValueHints !== undefined) editorSettings.value.showInsertValueHints = partial.showInsertValueHints === true;
if (partial.autoAliasTables !== undefined) editorSettings.value.autoAliasTables = partial.autoAliasTables;
if (partial.insertSpaceAfterCompletion !== undefined) editorSettings.value.insertSpaceAfterCompletion = partial.insertSpaceAfterCompletion === true;
if (partial.wordWrap !== undefined) editorSettings.value.wordWrap = partial.wordWrap;
if (partial.vimModeEnabled !== undefined) editorSettings.value.vimModeEnabled = partial.vimModeEnabled === true;
if (partial.autoCloseBrackets !== undefined) editorSettings.value.autoCloseBrackets = partial.autoCloseBrackets === true;

View File

@ -2596,6 +2596,19 @@ test("automatic table aliases avoid SQL keywords", () => {
}
});
test("does not force automatic table aliases when disabled", () => {
const sql = "select * from item";
const items = buildSqlCompletionItems(sql, sql.length, {
tables: [{ name: "item_file", schema: "public", type: "table" }],
columnsByTable,
autoAliasTables: false,
});
const tableItem = items.find((item) => item.type === "table" && item.label === "item_file");
assert.ok(tableItem);
assert.equal(tableItem!.apply, "item_file");
});
test("table alias suggestions avoid existing aliases", () => {
const items = buildSqlCompletionItems("select * from customer_orders co join customer_orders ", "select * from customer_orders co join customer_orders ".length, {
tables: [...tables, { name: "customer_orders", schema: "public", type: "table" }],