feat(editor): configure SQL completion column ordering

Closes #5955
This commit is contained in:
t8y2
2026-08-13 06:54:51 +00:00
parent 31a7609604
commit 18d581137c
10 changed files with 157 additions and 3 deletions
@@ -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<SqlCompletionTriggerMode>(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(() => {
<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-sort-completion-columns-alphabetically">{{ t("settings.sortCompletionColumnsAlphabetically") }}</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.sortCompletionColumnsAlphabeticallyDescription") }}
</p>
</div>
<Switch id="editor-sort-completion-columns-alphabetically" v-model="editSortCompletionColumnsAlphabetically" 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>
@@ -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;
+2
View File
@@ -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",
+2
View File
@@ -5792,6 +5792,8 @@ export default withEnglishFallback({
autoCloseBracketsDescription: "输入左括号或左引号时自动补全对应的右括号或右引号",
insertSpaceAfterCompletion: "补全后自动添加空格",
insertSpaceAfterCompletionDescription: "选择关键字、表名或列名补全后,在后续字符允许时自动追加空格",
sortCompletionColumnsAlphabetically: "按字母顺序排列补全字段",
sortCompletionColumnsAlphabeticallyDescription: "关闭后,表字段将保持数据库中的原始顺序",
completionTriggerMode: "自动补全触发方式",
completionTriggerModeDescription: "控制 SQL 补全弹窗何时自动打开。仅手动提示需快捷键;输入标识符后提示需输入至少一个字符;智能位置提示保持当前行为。",
completionTriggerModeManual: "仅手动提示",
@@ -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();
});
});
@@ -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);
}
@@ -20,6 +20,7 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [
"showInsertValueHints",
"autoAliasTables",
"insertSpaceAfterCompletion",
"sortCompletionColumnsAlphabetically",
"wordWrap",
"vimModeEnabled",
"autoCloseBrackets",
+4
View File
@@ -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<EditorSettings>, 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;
+8
View File
@@ -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);
});
+23
View File
@@ -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());