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); +
+
+ +

{{ t("settings.insertSpaceAfterCompletionDescription") }}

+
+ +
+
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" }],