From 931cff48c0e821645ff2d3099c9c2aedbad560bc Mon Sep 17 00:00:00 2001
From: t8y2 <1156263951@qq.com>
Date: Sun, 26 Jul 2026 13:10:02 +0800
Subject: [PATCH] fix(editor): improve SQL completion insertion
---
.../editor/EditorSettingsDialog.vue | 13 ++++++++++
.../src/components/editor/QueryEditor.vue | 13 ++++++++--
apps/desktop/src/i18n/locales/en.ts | 2 ++
apps/desktop/src/i18n/locales/zh-CN.ts | 2 ++
.../queryEditorCompletionKeymap.spec.ts | 12 ++++++++++
.../editor/sqlCompletionInsertion.spec.ts | 24 +++++++++++++++++++
.../src/lib/editor/sqlCompletionInsertion.ts | 14 +++++++++++
.../src/lib/settings/editorSettingsDraft.ts | 1 +
.../stores/__tests__/settingsStore.spec.ts | 5 ++++
apps/desktop/src/stores/settingsStore.ts | 4 ++++
packages/app-tests/sqlCompletion.test.ts | 13 ++++++++++
11 files changed, 101 insertions(+), 2 deletions(-)
create mode 100644 apps/desktop/src/lib/__tests__/editor/sqlCompletionInsertion.spec.ts
create mode 100644 apps/desktop/src/lib/editor/sqlCompletionInsertion.ts
diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue
index 306447576..d262873ff 100644
--- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue
+++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue
@@ -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);
+
diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue
index 8efb7f774..a8a91a457 100644
--- a/apps/desktop/src/components/editor/QueryEditor.vue
+++ b/apps/desktop/src/components/editor/QueryEditor.vue
@@ -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 {
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 {
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts
index 6b573355c..a3cc64a05 100644
--- a/apps/desktop/src/i18n/locales/en.ts
+++ b/apps/desktop/src/i18n/locales/en.ts
@@ -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",
diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts
index 81a2e7cac..d1d7975d1 100644
--- a/apps/desktop/src/i18n/locales/zh-CN.ts
+++ b/apps/desktop/src/i18n/locales/zh-CN.ts
@@ -4008,6 +4008,8 @@ export default withEnglishFallback({
vimModeDescription: "在 SQL 编辑器中使用 Vim 风格的模态编辑",
autoCloseBrackets: "自动成对补全",
autoCloseBracketsDescription: "输入左括号或左引号时自动补全对应的右括号或右引号",
+ insertSpaceAfterCompletion: "补全后自动添加空格",
+ insertSpaceAfterCompletionDescription: "选择关键字、表名或列名补全后,在后续字符允许时自动追加空格",
sqlSemanticDiagnosticsEnabled: "SQL 语义诊断",
sqlSemanticDiagnosticsEnabledDescription: "开启后,编辑器会提示未知表、字段等语义问题;关闭可减少 SQL 解析和元数据检查的性能开销。",
confirmDangerousSqlExecution: "执行危险 SQL 前弹出确认",
diff --git a/apps/desktop/src/lib/__tests__/editor/queryEditorCompletionKeymap.spec.ts b/apps/desktop/src/lib/__tests__/editor/queryEditorCompletionKeymap.spec.ts
index d815b97cb..230e1fbe3 100644
--- a/apps/desktop/src/lib/__tests__/editor/queryEditorCompletionKeymap.spec.ts
+++ b/apps/desktop/src/lib/__tests__/editor/queryEditorCompletionKeymap.spec.ts
@@ -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();
diff --git a/apps/desktop/src/lib/__tests__/editor/sqlCompletionInsertion.spec.ts b/apps/desktop/src/lib/__tests__/editor/sqlCompletionInsertion.spec.ts
new file mode 100644
index 000000000..b100b7ace
--- /dev/null
+++ b/apps/desktop/src/lib/__tests__/editor/sqlCompletionInsertion.spec.ts
@@ -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()");
+ });
+});
diff --git a/apps/desktop/src/lib/editor/sqlCompletionInsertion.ts b/apps/desktop/src/lib/editor/sqlCompletionInsertion.ts
new file mode 100644
index 000000000..c801d38bc
--- /dev/null
+++ b/apps/desktop/src/lib/editor/sqlCompletionInsertion.ts
@@ -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} `;
+}
diff --git a/apps/desktop/src/lib/settings/editorSettingsDraft.ts b/apps/desktop/src/lib/settings/editorSettingsDraft.ts
index e9d071c8e..441823b45 100644
--- a/apps/desktop/src/lib/settings/editorSettingsDraft.ts
+++ b/apps/desktop/src/lib/settings/editorSettingsDraft.ts
@@ -16,6 +16,7 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [
"showCurrentStatementFrame",
"showInsertValueHints",
"autoAliasTables",
+ "insertSpaceAfterCompletion",
"wordWrap",
"vimModeEnabled",
"autoCloseBrackets",
diff --git a/apps/desktop/src/stores/__tests__/settingsStore.spec.ts b/apps/desktop/src/stores/__tests__/settingsStore.spec.ts
index 77a0adf06..3258d6e8a 100644
--- a/apps/desktop/src/stores/__tests__/settingsStore.spec.ts
+++ b/apps/desktop/src/stores/__tests__/settingsStore.spec.ts
@@ -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");
diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts
index 33c7e8693..be63c1a4b 100644
--- a/apps/desktop/src/stores/settingsStore.ts
+++ b/apps/desktop/src/stores/settingsStore.ts
@@ -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, 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;
diff --git a/packages/app-tests/sqlCompletion.test.ts b/packages/app-tests/sqlCompletion.test.ts
index 9e189befd..8a2f7b922 100644
--- a/packages/app-tests/sqlCompletion.test.ts
+++ b/packages/app-tests/sqlCompletion.test.ts
@@ -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" }],