From 18d581137cc67997b68be2cd7e2b74b6ec1f257d Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Thu, 13 Aug 2026 06:46:54 +0000 Subject: [PATCH] feat(editor): configure SQL completion column ordering Closes #5955 --- .../editor/EditorSettingsDialog.vue | 15 ++++ .../src/components/editor/QueryEditor.vue | 5 +- apps/desktop/src/i18n/locales/en.ts | 2 + apps/desktop/src/i18n/locales/zh-CN.ts | 2 + .../editor/sqlCompletionPresentation.spec.ts | 89 ++++++++++++++++++- .../lib/editor/sqlCompletionPresentation.ts | 11 +++ .../src/lib/settings/editorSettingsDraft.ts | 1 + apps/desktop/src/stores/settingsStore.ts | 4 + .../app-tests/editorSettingsDraft.test.ts | 8 ++ packages/app-tests/settingsStore.test.ts | 23 +++++ 10 files changed, 157 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index 0c0cbfe88a..b5cd615dd5 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -281,6 +281,7 @@ const editShowCurrentStatementFrame = ref(settingsStore.editorSettings.showCurre const editShowInsertValueHints = ref(settingsStore.editorSettings.showInsertValueHints); const editAutoAliasTables = ref(settingsStore.editorSettings.autoAliasTables); const editInsertSpaceAfterCompletion = ref(settingsStore.editorSettings.insertSpaceAfterCompletion); +const editSortCompletionColumnsAlphabetically = ref(settingsStore.editorSettings.sortCompletionColumnsAlphabetically); const editCompletionTriggerMode = ref(settingsStore.editorSettings.completionTriggerMode); const editWordWrap = ref(settingsStore.editorSettings.wordWrap); const editVimModeEnabled = ref(settingsStore.editorSettings.vimModeEnabled); @@ -472,6 +473,7 @@ function currentEditorSettingsDraft(): EditorSettingsDraft { showInsertValueHints: editShowInsertValueHints.value, autoAliasTables: editAutoAliasTables.value, insertSpaceAfterCompletion: editInsertSpaceAfterCompletion.value, + sortCompletionColumnsAlphabetically: editSortCompletionColumnsAlphabetically.value, completionTriggerMode: editCompletionTriggerMode.value, wordWrap: editWordWrap.value, vimModeEnabled: editVimModeEnabled.value, @@ -746,6 +748,7 @@ function syncEditorSettingsDraftFromStore() { editShowInsertValueHints.value = settingsStore.editorSettings.showInsertValueHints; editAutoAliasTables.value = settingsStore.editorSettings.autoAliasTables; editInsertSpaceAfterCompletion.value = settingsStore.editorSettings.insertSpaceAfterCompletion; + editSortCompletionColumnsAlphabetically.value = settingsStore.editorSettings.sortCompletionColumnsAlphabetically; editCompletionTriggerMode.value = settingsStore.editorSettings.completionTriggerMode; editWordWrap.value = settingsStore.editorSettings.wordWrap; editVimModeEnabled.value = settingsStore.editorSettings.vimModeEnabled; @@ -958,6 +961,7 @@ function resetDefaultsForTab(tab: SettingsCategory) { editShowInsertValueHints.value = DEFAULT_EDITOR_SETTINGS.showInsertValueHints; editAutoAliasTables.value = DEFAULT_EDITOR_SETTINGS.autoAliasTables; editInsertSpaceAfterCompletion.value = DEFAULT_EDITOR_SETTINGS.insertSpaceAfterCompletion; + editSortCompletionColumnsAlphabetically.value = DEFAULT_EDITOR_SETTINGS.sortCompletionColumnsAlphabetically; editCompletionTriggerMode.value = DEFAULT_EDITOR_SETTINGS.completionTriggerMode; editWordWrap.value = DEFAULT_EDITOR_SETTINGS.wordWrap; editVimModeEnabled.value = DEFAULT_EDITOR_SETTINGS.vimModeEnabled; @@ -1057,6 +1061,7 @@ function resetAllDefaults() { editShowInsertValueHints.value = DEFAULT_EDITOR_SETTINGS.showInsertValueHints; editAutoAliasTables.value = DEFAULT_EDITOR_SETTINGS.autoAliasTables; editInsertSpaceAfterCompletion.value = DEFAULT_EDITOR_SETTINGS.insertSpaceAfterCompletion; + editSortCompletionColumnsAlphabetically.value = DEFAULT_EDITOR_SETTINGS.sortCompletionColumnsAlphabetically; editWordWrap.value = DEFAULT_EDITOR_SETTINGS.wordWrap; editVimModeEnabled.value = DEFAULT_EDITOR_SETTINGS.vimModeEnabled; editAutoCloseBrackets.value = DEFAULT_EDITOR_SETTINGS.autoCloseBrackets; @@ -3791,6 +3796,16 @@ onUnmounted(() => { +
+
+ +

+ {{ t("settings.sortCompletionColumnsAlphabeticallyDescription") }} +

+
+ +
+
diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 97227ca56c..d997bd1c6e 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -101,7 +101,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 { compareSqlCompletions, 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"; @@ -4587,6 +4587,7 @@ onMounted(async () => { buildSqlCompletionExtension = () => autocompletion({ activateOnTyping: true, + compareCompletions: (a, b) => compareSqlCompletions(a, b, settingsStore.editorSettings.sortCompletionColumnsAlphabetically), override: [async (context: CompletionContext) => provideSqlCompletions(context)], }); @@ -5400,7 +5401,7 @@ watch( ); watch( - () => settingsStore.editorSettings.snippets, + () => [settingsStore.editorSettings.snippets, settingsStore.editorSettings.sortCompletionColumnsAlphabetically], () => { completionEpoch++; if (!view.value || !completionComp || !buildSqlCompletionExtension) return; diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index fdc0beb6be..03b0cfcb20 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -5806,6 +5806,8 @@ export default { 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", + sortCompletionColumnsAlphabetically: "Sort completion columns alphabetically", + sortCompletionColumnsAlphabeticallyDescription: "Turn off to keep table fields in their original database order", completionTriggerMode: "Auto-completion trigger mode", completionTriggerModeDescription: "Controls when the SQL completion popup opens automatically. Manual requires a shortcut key; Typed requires a typed identifier prefix; Smart preserves the current behavior.", completionTriggerModeManual: "Manual only", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 34ed50007d..baad13e503 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -5792,6 +5792,8 @@ export default withEnglishFallback({ autoCloseBracketsDescription: "输入左括号或左引号时自动补全对应的右括号或右引号", insertSpaceAfterCompletion: "补全后自动添加空格", insertSpaceAfterCompletionDescription: "选择关键字、表名或列名补全后,在后续字符允许时自动追加空格", + sortCompletionColumnsAlphabetically: "按字母顺序排列补全字段", + sortCompletionColumnsAlphabeticallyDescription: "关闭后,表字段将保持数据库中的原始顺序", completionTriggerMode: "自动补全触发方式", completionTriggerModeDescription: "控制 SQL 补全弹窗何时自动打开。仅手动提示需快捷键;输入标识符后提示需输入至少一个字符;智能位置提示保持当前行为。", completionTriggerModeManual: "仅手动提示", diff --git a/apps/desktop/src/lib/__tests__/editor/sqlCompletionPresentation.spec.ts b/apps/desktop/src/lib/__tests__/editor/sqlCompletionPresentation.spec.ts index 966213323b..735be9d1b0 100644 --- a/apps/desktop/src/lib/__tests__/editor/sqlCompletionPresentation.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/sqlCompletionPresentation.spec.ts @@ -4,7 +4,7 @@ import { acceptCompletion, autocompletion, completionStatus, currentCompletions, import { EditorState } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { describe, expect, it } from "vitest"; -import { completionLabelPresentation } from "@/lib/editor/sqlCompletionPresentation"; +import { compareSqlCompletions, completionLabelPresentation } from "@/lib/editor/sqlCompletionPresentation"; describe("SQL completion label presentation", () => { it("filters a custom snippet by trigger while preserving its display label", () => { @@ -44,3 +44,90 @@ describe("SQL completion label presentation", () => { view.destroy(); }); }); + +describe("SQL completion order", () => { + it("keeps the current alphabetical tie-break by default", () => { + const items = [ + { label: "id", type: "column" }, + { label: "z_last", type: "column" }, + { label: "a_first", type: "column" }, + ]; + + expect([...items].sort((a, b) => compareSqlCompletions(a, b, true)).map((item) => item.label)).toEqual(["a_first", "id", "z_last"]); + }); + + it("preserves database order for column ties without changing their boosts", () => { + const items = [ + { label: "id", type: "column", boost: 100 }, + { label: "z_last", type: "column", boost: 10 }, + { label: "a_first", type: "column", boost: 10 }, + ]; + + expect([...items].sort((a, b) => compareSqlCompletions(a, b, false)).map((item) => item.label)).toEqual(["id", "z_last", "a_first"]); + expect(items.map((item) => item.boost)).toEqual([100, 10, 10]); + }); + + it("keeps label ordering for non-columns and mixed item types", () => { + expect(compareSqlCompletions({ label: "SELECT", type: "keyword" }, { label: "FROM", type: "keyword" }, false)).toBeGreaterThan(0); + expect(compareSqlCompletions({ label: "z_column", type: "column" }, { label: "a_table", type: "table" }, false)).toBeGreaterThan(0); + expect(compareSqlCompletions({ label: "visible", sortText: "a_sort", type: "snippet" }, { label: "before", type: "keyword" }, false)).toBeLessThan(0); + }); + + it("preserves database order in CodeMirror when column match scores tie", async () => { + const view = new EditorView({ + parent: document.createElement("div"), + state: EditorState.create({ + extensions: [ + autocompletion({ + interactionDelay: 0, + compareCompletions: (a, b) => compareSqlCompletions(a, b, false), + override: [ + () => ({ + from: 0, + options: [ + { label: "id", type: "column", boost: 100 }, + { label: "z_last", type: "column", boost: 10 }, + { label: "a_first", type: "column", boost: 10 }, + ], + }), + ], + }), + ], + }), + }); + + startCompletion(view); + await expect.poll(() => completionStatus(view.state)).toBe("active"); + expect(currentCompletions(view.state).map((item) => item.label)).toEqual(["id", "z_last", "a_first"]); + view.destroy(); + }); + + it("keeps the default CodeMirror alphabetical tie-break and key priority", async () => { + const view = new EditorView({ + parent: document.createElement("div"), + state: EditorState.create({ + extensions: [ + autocompletion({ + interactionDelay: 0, + compareCompletions: (a, b) => compareSqlCompletions(a, b, true), + override: [ + () => ({ + from: 0, + options: [ + { label: "id", type: "column", boost: 100 }, + { label: "z_last", type: "column", boost: 10 }, + { label: "a_first", type: "column", boost: 10 }, + ], + }), + ], + }), + ], + }), + }); + + startCompletion(view); + await expect.poll(() => completionStatus(view.state)).toBe("active"); + expect(currentCompletions(view.state).map((item) => item.label)).toEqual(["id", "a_first", "z_last"]); + view.destroy(); + }); +}); diff --git a/apps/desktop/src/lib/editor/sqlCompletionPresentation.ts b/apps/desktop/src/lib/editor/sqlCompletionPresentation.ts index e0eb6722b2..5d66f5baec 100644 --- a/apps/desktop/src/lib/editor/sqlCompletionPresentation.ts +++ b/apps/desktop/src/lib/editor/sqlCompletionPresentation.ts @@ -4,6 +4,12 @@ export interface CompletionLabelPresentation { sortText?: string; } +export interface CompletionSortItem { + label: string; + sortText?: string; + type?: string; +} + export function completionLabelPresentation(label: string, filterText?: string): CompletionLabelPresentation { const normalizedFilterText = filterText?.trim(); if (!normalizedFilterText || label.toLowerCase().startsWith(normalizedFilterText.toLowerCase())) return { label }; @@ -13,3 +19,8 @@ export function completionLabelPresentation(label: string, filterText?: string): sortText: label, }; } + +export function compareSqlCompletions(a: CompletionSortItem, b: CompletionSortItem, sortColumnsAlphabetically: boolean): number { + if (!sortColumnsAlphabetically && a.type === "column" && b.type === "column") return 0; + return (a.sortText || a.label).localeCompare(b.sortText || b.label); +} diff --git a/apps/desktop/src/lib/settings/editorSettingsDraft.ts b/apps/desktop/src/lib/settings/editorSettingsDraft.ts index f538e6a6f6..f967ce2ca3 100644 --- a/apps/desktop/src/lib/settings/editorSettingsDraft.ts +++ b/apps/desktop/src/lib/settings/editorSettingsDraft.ts @@ -20,6 +20,7 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [ "showInsertValueHints", "autoAliasTables", "insertSpaceAfterCompletion", + "sortCompletionColumnsAlphabetically", "wordWrap", "vimModeEnabled", "autoCloseBrackets", diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index fb2b327b2c..cf9b992ff9 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -515,6 +515,7 @@ export interface EditorSettings { showInsertValueHints: boolean; autoAliasTables: boolean; insertSpaceAfterCompletion: boolean; + sortCompletionColumnsAlphabetically: boolean; wordWrap: boolean; vimModeEnabled: boolean; autoCloseBrackets: boolean; @@ -710,6 +711,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = { showInsertValueHints: true, autoAliasTables: true, insertSpaceAfterCompletion: true, + sortCompletionColumnsAlphabetically: true, wordWrap: false, vimModeEnabled: false, autoCloseBrackets: true, @@ -1049,6 +1051,7 @@ export function normalizeEditorSettings(settings: Partial, exist 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, + sortCompletionColumnsAlphabetically: typeof settings.sortCompletionColumnsAlphabetically === "boolean" ? settings.sortCompletionColumnsAlphabetically : DEFAULT_EDITOR_SETTINGS.sortCompletionColumnsAlphabetically, 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, @@ -1621,6 +1624,7 @@ export const useSettingsStore = defineStore("settings", () => { 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.sortCompletionColumnsAlphabetically !== undefined) editorSettings.value.sortCompletionColumnsAlphabetically = partial.sortCompletionColumnsAlphabetically === 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/editorSettingsDraft.test.ts b/packages/app-tests/editorSettingsDraft.test.ts index 527c8adb4d..2897a74a02 100644 --- a/packages/app-tests/editorSettingsDraft.test.ts +++ b/packages/app-tests/editorSettingsDraft.test.ts @@ -40,3 +40,11 @@ test("applies a result grid font without overwriting editor or interface fonts", assert.equal(patch.tableFontFamily, tableFontFamily); assert.equal(store.editorSettings.tableFontFamily, tableFontFamily); }); + +test("round-trips completion column ordering through the editor settings draft", () => { + const base = editorSettingsDraftFromSettings(DEFAULT_EDITOR_SETTINGS); + const patch = editorSettingsPatchFromDraft({ ...base, sortCompletionColumnsAlphabetically: false }, base); + + assert.deepEqual(patch, { sortCompletionColumnsAlphabetically: false }); + assert.equal(editorSettingsDraftFromSettings({ ...DEFAULT_EDITOR_SETTINGS, ...patch }).sortCompletionColumnsAlphabetically, false); +}); diff --git a/packages/app-tests/settingsStore.test.ts b/packages/app-tests/settingsStore.test.ts index 0ff860ece9..0082b15528 100644 --- a/packages/app-tests/settingsStore.test.ts +++ b/packages/app-tests/settingsStore.test.ts @@ -99,6 +99,29 @@ test("numericColumnRightAlign defaults to true and round-trips through normalize assert.equal(normalizeEditorSettings({ numericColumnRightAlign: "false" as unknown as boolean }).numericColumnRightAlign, true); }); +test("completion column sorting defaults to alphabetical and normalizes saved booleans", () => { + assert.equal(DEFAULT_EDITOR_SETTINGS.sortCompletionColumnsAlphabetically, true); + assert.equal(normalizeEditorSettings({}).sortCompletionColumnsAlphabetically, true); + assert.equal(normalizeEditorSettings({ sortCompletionColumnsAlphabetically: false }).sortCompletionColumnsAlphabetically, false); + assert.equal(normalizeEditorSettings({ sortCompletionColumnsAlphabetically: true }).sortCompletionColumnsAlphabetically, true); + assert.equal(normalizeEditorSettings({ sortCompletionColumnsAlphabetically: "false" as unknown as boolean }).sortCompletionColumnsAlphabetically, true); +}); + +test("updateEditorSettings persists completion column sort toggles", async () => { + await withMockLocalStorage({}, async () => { + setActivePinia(createPinia()); + const store = useSettingsStore(); + await store.initEditorSettings(); + + store.updateEditorSettings({ sortCompletionColumnsAlphabetically: false }); + assert.equal(store.editorSettings.sortCompletionColumnsAlphabetically, false); + await vi.waitFor(() => { + const saved = saveEditorSettingsMock.mock.calls.at(-1)?.[0] as { sortCompletionColumnsAlphabetically?: boolean } | undefined; + assert.equal(saved?.sortCompletionColumnsAlphabetically, false); + }); + }); +}); + test("updateEditorSettings persists numericColumnRightAlign toggles", async () => { await withMockLocalStorage({}, async () => { setActivePinia(createPinia());