From c6e9e94c58c7b9ba2371117ccfe5ce86e103b130 Mon Sep 17 00:00:00 2001 From: zipg Date: Tue, 25 Aug 2026 21:52:01 +0800 Subject: [PATCH] fix(editor): restore configurable completion acceptance --- .../editor/EditorSettingsDialog.vue | 127 +++++++++++------- .../src/components/editor/QueryEditor.vue | 50 ++++++- apps/desktop/src/i18n/locales/en.ts | 3 + apps/desktop/src/i18n/locales/es.ts | 3 + apps/desktop/src/i18n/locales/it.ts | 3 + apps/desktop/src/i18n/locales/ja.ts | 3 + apps/desktop/src/i18n/locales/ko.ts | 3 + apps/desktop/src/i18n/locales/pt-BR.ts | 3 + apps/desktop/src/i18n/locales/zh-CN.ts | 3 + apps/desktop/src/i18n/locales/zh-TW.ts | 3 + .../queryEditorCompletionKeymap.spec.ts | 33 ++++- .../queryEditorCompletionSelection.spec.ts | 109 ++++++++++++--- .../editor/queryEditorCompletionAcceptance.ts | 77 +++++++++++ .../src/lib/settings/editorSettingsDraft.ts | 1 + .../stores/__tests__/settingsStore.spec.ts | 7 + apps/desktop/src/stores/settingsStore.ts | 4 + .../app-tests/editorSettingsDraft.test.ts | 8 ++ 17 files changed, 367 insertions(+), 73 deletions(-) create mode 100644 apps/desktop/src/lib/editor/queryEditorCompletionAcceptance.ts diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index 8c32e347e2..5b4067dceb 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -332,6 +332,7 @@ const editShowInsertValueHints = ref(settingsStore.editorSettings.showInsertValu const editAutoAliasTables = ref(settingsStore.editorSettings.autoAliasTables); const editInsertSpaceAfterCompletion = ref(settingsStore.editorSettings.insertSpaceAfterCompletion); const editSortCompletionColumnsAlphabetically = ref(settingsStore.editorSettings.sortCompletionColumnsAlphabetically); +const editSelectFirstCompletionOnOpen = ref(settingsStore.editorSettings.selectFirstCompletionOnOpen); const editCompletionTriggerMode = ref(settingsStore.editorSettings.completionTriggerMode); const editWordWrap = ref(settingsStore.editorSettings.wordWrap); const editVimModeEnabled = ref(settingsStore.editorSettings.vimModeEnabled); @@ -536,6 +537,7 @@ function currentEditorSettingsDraft(): EditorSettingsDraft { autoAliasTables: editAutoAliasTables.value, insertSpaceAfterCompletion: editInsertSpaceAfterCompletion.value, sortCompletionColumnsAlphabetically: editSortCompletionColumnsAlphabetically.value, + selectFirstCompletionOnOpen: editSelectFirstCompletionOnOpen.value, completionTriggerMode: editCompletionTriggerMode.value, wordWrap: editWordWrap.value, vimModeEnabled: editVimModeEnabled.value, @@ -825,6 +827,7 @@ function syncEditorSettingsDraftFromStore() { editAutoAliasTables.value = settingsStore.editorSettings.autoAliasTables; editInsertSpaceAfterCompletion.value = settingsStore.editorSettings.insertSpaceAfterCompletion; editSortCompletionColumnsAlphabetically.value = settingsStore.editorSettings.sortCompletionColumnsAlphabetically; + editSelectFirstCompletionOnOpen.value = settingsStore.editorSettings.selectFirstCompletionOnOpen; editCompletionTriggerMode.value = settingsStore.editorSettings.completionTriggerMode; editWordWrap.value = settingsStore.editorSettings.wordWrap; editVimModeEnabled.value = settingsStore.editorSettings.vimModeEnabled; @@ -1079,6 +1082,7 @@ function resetDefaultsForTab(tab: SettingsCategory) { editAutoAliasTables.value = DEFAULT_EDITOR_SETTINGS.autoAliasTables; editInsertSpaceAfterCompletion.value = DEFAULT_EDITOR_SETTINGS.insertSpaceAfterCompletion; editSortCompletionColumnsAlphabetically.value = DEFAULT_EDITOR_SETTINGS.sortCompletionColumnsAlphabetically; + editSelectFirstCompletionOnOpen.value = DEFAULT_EDITOR_SETTINGS.selectFirstCompletionOnOpen; editCompletionTriggerMode.value = DEFAULT_EDITOR_SETTINGS.completionTriggerMode; editWordWrap.value = DEFAULT_EDITOR_SETTINGS.wordWrap; editVimModeEnabled.value = DEFAULT_EDITOR_SETTINGS.vimModeEnabled; @@ -1194,6 +1198,7 @@ function resetAllDefaults() { editAutoAliasTables.value = DEFAULT_EDITOR_SETTINGS.autoAliasTables; editInsertSpaceAfterCompletion.value = DEFAULT_EDITOR_SETTINGS.insertSpaceAfterCompletion; editSortCompletionColumnsAlphabetically.value = DEFAULT_EDITOR_SETTINGS.sortCompletionColumnsAlphabetically; + editSelectFirstCompletionOnOpen.value = DEFAULT_EDITOR_SETTINGS.selectFirstCompletionOnOpen; editWordWrap.value = DEFAULT_EDITOR_SETTINGS.wordWrap; editVimModeEnabled.value = DEFAULT_EDITOR_SETTINGS.vimModeEnabled; editAutoCloseBrackets.value = DEFAULT_EDITOR_SETTINGS.autoCloseBrackets; @@ -4030,16 +4035,6 @@ onUnmounted(() => { -
-
- -

- {{ t("settings.autoCloseBracketsDescription") }} -

-
- -
-
@@ -4055,55 +4050,87 @@ onUnmounted(() => { class="h-7 w-24 px-2 text-xs tabular-nums [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" />
+
-
-
- -

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

-
- + + +
+
+ {{ t("settings.sqlCompletionSection") }}
-
-
- +
+
+
+ +

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

+
+ +
+ +
+
+ +

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

+
+ +
+ +
+
+ +

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

+
+ +
+ +
+
+ +

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

+
+ +
+ +
+
+ +

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

+
+ +
+ +
+ +

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

- -
- -
-
- -

- {{ t("settings.autoAliasTablesDescription") }} -

-
- -
- -
- - -

- {{ t("settings.completionTriggerModeDescription") }} -

+ +
diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 2b09dd7fbd..2ba7bf5e36 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -131,6 +131,7 @@ import { createShellLineCommentHighlight } from "@/lib/editor/codemirrorShellLin import { extendQueryEditorSelection, runQueryEditorAltExtendSelection } from "@/lib/editor/queryEditorExtendSelection"; import { createQueryEditorStringMouseSelection } from "@/lib/editor/queryEditorStringMouseSelection"; import { createQueryEditorCompletionShortcutBindings } from "@/lib/editor/queryEditorCompletionShortcut"; +import { acceptSelectedCompletionWithRetry, acceptSelectedOrFirstCompletion } from "@/lib/editor/queryEditorCompletionAcceptance"; import type { StatementExecutionMarker } from "@/lib/tabs/tabPresentation"; import { isSchemaAware, isSingleDatabase, supportsDatabaseNameCompletion, supportsDatabaseSchemaQualifier, supportsQueryEditorBlockComments, supportsSqlInListPaste } from "@/lib/database/databaseFeatureSupport"; import { metadataSchemaForConnection, sqlSnippetDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; @@ -202,6 +203,7 @@ const COMPLETION_REMOTE_LATENCY_BUDGET_MS = 120; const COMPLETION_DEBOUNCE_DELAY_MS = 150; const COMPLETION_TAB_RETRY_DELAY_MS = 16; const COMPLETION_TAB_MAX_WAIT_MS = COMPLETION_DEBOUNCE_DELAY_MS + COMPLETION_REMOTE_LATENCY_BUDGET_MS + 100; +const COMPLETION_ENTER_MAX_WAIT_MS = 125; // Internal rollback switch: flip to false to route completion, diagnostics, and navigation through the legacy SQL context path. const SEMANTIC_SQL_COMPLETION_ENABLED = true; @@ -554,6 +556,8 @@ let buildSqlSemanticHighlightExtension: (() => import("@codemirror/state").Exten let codeMirrorSnippetCompletion: typeof import("@codemirror/autocomplete").snippetCompletion; let codeMirrorCompletionStatus: typeof import("@codemirror/autocomplete").completionStatus | null = null; let codeMirrorAcceptCompletion: typeof import("@codemirror/autocomplete").acceptCompletion | null = null; +let codeMirrorSelectedCompletionIndex: typeof import("@codemirror/autocomplete").selectedCompletionIndex | null = null; +let codeMirrorSelectFirstCompletion: import("@codemirror/view").Command | null = null; let codeMirrorStartCompletion: typeof import("@codemirror/autocomplete").startCompletion | null = null; let codeMirrorCloseCompletion: typeof import("@codemirror/autocomplete").closeCompletion | null = null; let codeMirrorInsertCompletionText: typeof import("@codemirror/autocomplete").insertCompletionText | null = null; @@ -574,6 +578,7 @@ let codeMirrorToggleBlockComment: typeof import("@codemirror/commands").toggleBl let codeMirrorDefaultKeymap: readonly import("@codemirror/view").KeyBinding[] | null = null; let codeMirrorToggleFold: typeof import("@codemirror/language").toggleFold | null = null; let pendingCompletionTabTimer: ReturnType | null = null; +let cancelPendingCompletionEnter: (() => void) | null = null; let setSqlDiagnosticsEffect: import("@codemirror/state").StateEffectType | null = null; let setPreviewRangeEffect: | import("@codemirror/state").StateEffectType<{ @@ -1925,7 +1930,7 @@ function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view") codeMirrorKeymap.of([ { key: "Enter", - run: insertNewlineWithoutCompletion, + run: handleEnter, }, ...binding(shortcuts.find, openSearch), ...binding(shortcuts.replace, openReplace), @@ -1995,6 +2000,36 @@ function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view") ]; } +function handleEnter(view: EditorViewType): boolean { + clearPendingCompletionEnter(); + if (settingsStore.editorSettings.selectFirstCompletionOnOpen && codeMirrorCompletionStatus) { + let cancelRetry: (() => void) | null = null; + const result = acceptSelectedCompletionWithRetry(view, { + completionStatus: codeMirrorCompletionStatus, + acceptCompletion: codeMirrorAcceptCompletion, + selectedCompletionIndex: codeMirrorSelectedCompletionIndex, + selectFirstCompletion: codeMirrorSelectFirstCompletion, + retryDelayMs: COMPLETION_TAB_RETRY_DELAY_MS, + maxWaitMs: COMPLETION_ENTER_MAX_WAIT_MS, + onUnavailable: () => insertNewlineWithoutCompletion(view), + onSettled: () => { + if (cancelPendingCompletionEnter === cancelRetry) cancelPendingCompletionEnter = null; + }, + }); + if (result.handled) { + cancelRetry = result.cancel ?? null; + cancelPendingCompletionEnter = cancelRetry; + return true; + } + } + return insertNewlineWithoutCompletion(view); +} + +function clearPendingCompletionEnter() { + cancelPendingCompletionEnter?.(); + cancelPendingCompletionEnter = null; +} + function insertNewlineWithoutCompletion(view: EditorViewType): boolean { codeMirrorCloseCompletion?.(view); suppressNextSqlCompletionAutoStartUntil = Date.now() + 750; @@ -2018,7 +2053,7 @@ function acceptCompletionOrNextSnippetField(view: EditorViewType): boolean { // of the indent edit itself, so it must never hijack this or a following Tab. if (view.state.selection.ranges.every((range) => range.empty)) { const completionStatus = codeMirrorCompletionStatus?.(view.state) ?? null; - if (completionStatus === "active" && (codeMirrorAcceptCompletion?.(view) ?? false)) return true; + if (completionStatus === "active" && acceptSelectedOrFirstCompletion(view, codeMirrorAcceptCompletion, codeMirrorSelectedCompletionIndex, codeMirrorSelectFirstCompletion)) return true; if (completionStatus) return waitForCompletionTab(view); } return codeMirrorNextSnippetField?.(view) ?? false; @@ -2042,7 +2077,7 @@ function waitForCompletionTab(view: EditorViewType): boolean { if (view.state.doc !== initialDoc || selectionRanges.length !== initialSelectionRanges.length || selectionRanges.some((range, index) => !range.empty || range.anchor !== initialSelectionRanges[index]?.anchor || range.head !== initialSelectionRanges[index]?.head)) return; const completionStatus = codeMirrorCompletionStatus?.(view.state) ?? null; - if (completionStatus === "active" && (codeMirrorAcceptCompletion?.(view) ?? false)) return; + if (completionStatus === "active" && acceptSelectedOrFirstCompletion(view, codeMirrorAcceptCompletion, codeMirrorSelectedCompletionIndex, codeMirrorSelectFirstCompletion)) return; if (completionStatus && Date.now() - startedAt < COMPLETION_TAB_MAX_WAIT_MS) { pendingCompletionTabTimer = setTimeout(retry, COMPLETION_TAB_RETRY_DELAY_MS); return; @@ -4661,7 +4696,7 @@ onMounted(async () => { }, { EditorState, EditorSelection, Compartment, Prec, RangeSet, StateEffect, StateField }, langSql, - { autocompletion, startCompletion, acceptCompletion, closeBrackets, closeBracketsKeymap, snippetCompletion, completionStatus, completionKeymap, insertCompletionText, nextSnippetField, closeCompletion }, + { autocompletion, startCompletion, acceptCompletion, closeBrackets, closeBracketsKeymap, snippetCompletion, completionStatus, completionKeymap, insertCompletionText, nextSnippetField, closeCompletion, moveCompletionSelection, selectedCompletionIndex }, { copyLineDown, copyLineUp, deleteLine, indentLess, indentMore, insertNewlineKeepIndent, moveLineDown, moveLineUp, redo, selectAll, undo, toggleLineComment, toggleBlockComment, history, defaultKeymap, historyKeymap }, { bracketMatching, foldGutter, indentOnInput, indentUnit, syntaxHighlighting, defaultHighlightStyle, foldKeymap, toggleFold, ensureSyntaxTree, highlightingFor, syntaxTree }, { searchKeymap }, @@ -4698,6 +4733,8 @@ onMounted(async () => { setSqlDiagnosticsEffect = StateEffect.define(); codeMirrorCompletionStatus = completionStatus; codeMirrorAcceptCompletion = acceptCompletion; + codeMirrorSelectedCompletionIndex = selectedCompletionIndex; + codeMirrorSelectFirstCompletion = moveCompletionSelection(true); codeMirrorCloseCompletion = closeCompletion; codeMirrorStartCompletion = startCompletion; codeMirrorInsertCompletionText = insertCompletionText; @@ -4931,7 +4968,7 @@ onMounted(async () => { buildSqlCompletionExtension = () => autocompletion({ activateOnTyping: true, - selectOnOpen: false, + selectOnOpen: settingsStore.editorSettings.selectFirstCompletionOnOpen, compareCompletions: (a, b) => compareSqlCompletions(a, b, settingsStore.editorSettings.sortCompletionColumnsAlphabetically), override: [async (context: CompletionContext) => provideSqlCompletions(context)], }); @@ -5841,7 +5878,7 @@ watch( ); watch( - () => [settingsStore.editorSettings.snippets, settingsStore.editorSettings.sortCompletionColumnsAlphabetically], + () => [settingsStore.editorSettings.snippets, settingsStore.editorSettings.sortCompletionColumnsAlphabetically, settingsStore.editorSettings.selectFirstCompletionOnOpen], () => { completionEpoch++; if (!view.value || !completionComp || !buildSqlCompletionExtension) return; @@ -5879,6 +5916,7 @@ function pauseQueryEditorBackgroundWork() { flushEditorViewport(); flushEditorSelection(); clearTableNavigationHover(); + clearPendingCompletionEnter(); clearPendingCompletionTab(); executionViewportOwnership.reset(); editorIsActive = false; diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 9a84f73547..6b02cb64bd 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -6429,10 +6429,13 @@ 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", + sqlCompletionSection: "SQL completion", 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", + selectFirstCompletionOnOpen: "Automatically select the first completion", + selectFirstCompletionOnOpenDescription: "When enabled, the first candidate is selected when the list opens, and Enter or Tab accepts it. When disabled, Enter inserts a new line and Tab accepts the first candidate.", 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/es.ts b/apps/desktop/src/i18n/locales/es.ts index bc9584fb87..2bdb66f408 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -6115,6 +6115,9 @@ export default withEnglishFallback({ vimModeDescription: "Usar edición modal estilo Vim en el editor SQL", autoCloseBrackets: "Cerrar paréntesis automáticamente", autoCloseBracketsDescription: "Insertar automáticamente paréntesis y comillas de cierre al escribir los de apertura", + sqlCompletionSection: "Autocompletado SQL", + selectFirstCompletionOnOpen: "Seleccionar automáticamente la primera sugerencia", + selectFirstCompletionOnOpenDescription: "Al activarlo, se selecciona la primera sugerencia al abrir la lista y se acepta con Enter o Tab. Al desactivarlo, Enter inserta una nueva línea y Tab acepta la primera sugerencia.", completionTriggerMode: "Modo de activación de autocompletado", completionTriggerModeDescription: "Controla cuándo se abre automáticamente la ventana de autocompletado SQL. Manual requiere una tecla de acceso rápido; Prefijo escrito requiere un identificador escrito; Inteligente conserva el comportamiento actual.", completionTriggerModeManual: "Solo manual", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 5eff0ede5d..3f28636f9d 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -6115,6 +6115,9 @@ export default withEnglishFallback({ vimModeDescription: "Usa la modifica modale in stile Vim nell'editor SQL", autoCloseBrackets: "Chiusura automatica parentesi", autoCloseBracketsDescription: "Inserisci automaticamente parentesi e virgolette di chiusura quando digiti quelle di apertura", + sqlCompletionSection: "Completamento SQL", + selectFirstCompletionOnOpen: "Seleziona automaticamente il primo suggerimento", + selectFirstCompletionOnOpenDescription: "Se attivo, il primo suggerimento viene selezionato all'apertura dell'elenco e accettato con Invio o Tab. Se disattivo, Invio inserisce una nuova riga e Tab accetta il primo suggerimento.", completionTriggerMode: "Modalità di attivazione autocompletamento", completionTriggerModeDescription: "Controlla quando si apre automaticamente il popup di autocompletamento SQL. Manuale richiede un tasto di scelta rapida; Prefisso digitato richiede un identificatore digitato; Intelligente mantiene il comportamento attuale.", completionTriggerModeManual: "Solo manuale", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 9123822ed9..10d19e19b0 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -6518,8 +6518,11 @@ export default withEnglishFallback({ syncSnippetRetryLegacyCleanup: "旧スニペットのクリーンアップを再試行", syncSnippetLegacyCleanupSuccess: "旧プレーンテキストスニペット {id} を削除しました。", syncSnippetGuide: "設定ガイド", + sqlCompletionSection: "SQL 補完", insertSpaceAfterCompletion: "補全後に自動でスペースを挿入", insertSpaceAfterCompletionDescription: "キーワード、テーブル名、列名の補全確定時、次の文字が許容する場合に自動でスペースを補填します", + selectFirstCompletionOnOpen: "最初の補完候補を自動選択", + selectFirstCompletionOnOpenDescription: "有効にすると、候補一覧を開いた時に先頭項目を選択し、Enter または Tab で確定します。無効にすると、Enter は改行し、Tab で先頭候補を確定します。", completionTriggerMode: "自動補完トリガーモード", completionTriggerModeDescription: "SQL補完ポップアップが自動的に開くタイミングを制御します。手動はショートカットキーが必要; 識別子入力後は1文字以上の入力が必要; スマート位置は現在の動作を維持します。", completionTriggerModeManual: "手動のみ", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 6c5cf0a270..58353dda22 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -5816,8 +5816,11 @@ export default withEnglishFallback({ vimModeDescription: "SQL 편집기에서 Vim 스타일의 모달 편집을 사용합니다", autoCloseBrackets: "괄호 자동 닫기", autoCloseBracketsDescription: "여는 괄호나 따옴표를 입력할 때 닫는 괄호와 따옴표를 자동으로 삽입합니다", + sqlCompletionSection: "SQL 자동 완성", insertSpaceAfterCompletion: "완성 후 공백 삽입", insertSpaceAfterCompletionDescription: "다음 문자가 허용할 때 키워드, 테이블 또는 컬럼 완성을 수락한 후 공백을 추가합니다", + selectFirstCompletionOnOpen: "첫 번째 자동 완성 후보 자동 선택", + selectFirstCompletionOnOpenDescription: "활성화하면 후보 목록이 열릴 때 첫 항목을 선택하고 Enter 또는 Tab으로 적용합니다. 비활성화하면 Enter는 줄을 바꾸고 Tab은 첫 후보를 적용합니다.", completionTriggerMode: "자동 완성 트리거 모드", completionTriggerModeDescription: "SQL 완성 팝업이 자동으로 열리는 시기를 제어합니다. 수동은 단축키 필요; 입력 식별자는 하나 이상의 문자 입력 필요; 스마트 위치는 현재 동작 유지.", completionTriggerModeManual: "수동만", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 23b1daf80a..0c87c582c0 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -6117,6 +6117,9 @@ export default withEnglishFallback({ vimModeDescription: "Usar edição modal no estilo Vim no editor SQL", autoCloseBrackets: "Fechar parênteses automaticamente", autoCloseBracketsDescription: "Inserir automaticamente parênteses e aspas de fechamento ao digitar os de abertura", + sqlCompletionSection: "Preenchimento de SQL", + selectFirstCompletionOnOpen: "Selecionar automaticamente a primeira sugestão", + selectFirstCompletionOnOpenDescription: "Quando ativado, a primeira sugestão é selecionada ao abrir a lista e aceita com Enter ou Tab. Quando desativado, Enter insere uma nova linha e Tab aceita a primeira sugestão.", completionTriggerMode: "Modo de acionamento do autocompletar", completionTriggerModeDescription: "Controla quando o popup de autocompletar SQL abre automaticamente. Manual requer uma tecla de atalho; Prefixo digitado requer um identificador digitado; Inteligente mantém o comportamento atual.", completionTriggerModeManual: "Apenas manual", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 2794dea266..5f604ead3b 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -6412,10 +6412,13 @@ export default withEnglishFallback({ vimModeDescription: "在 SQL 编辑器中使用 Vim 风格的模态编辑", autoCloseBrackets: "自动成对补全", autoCloseBracketsDescription: "输入左括号或左引号时自动补全对应的右括号或右引号", + sqlCompletionSection: "SQL 补全", insertSpaceAfterCompletion: "补全后自动添加空格", insertSpaceAfterCompletionDescription: "选择关键字、表名或列名补全后,在后续字符允许时自动追加空格", sortCompletionColumnsAlphabetically: "按字母顺序排列补全字段", sortCompletionColumnsAlphabeticallyDescription: "关闭后,表字段将保持数据库中的原始顺序", + selectFirstCompletionOnOpen: "自动选中首个补全候选", + selectFirstCompletionOnOpenDescription: "开启后,候选列表弹出时会选中第一项,可用 Enter 或 Tab 接受;关闭后,Enter 换行,Tab 接受第一项。", completionTriggerMode: "自动补全触发方式", completionTriggerModeDescription: "控制 SQL 补全弹窗何时自动打开。仅手动提示需快捷键;输入标识符后提示需输入至少一个字符;智能位置提示保持当前行为。", completionTriggerModeManual: "仅手动提示", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 58515aaf1e..3c0870dd5f 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -5433,6 +5433,9 @@ export default withEnglishFallback({ vimModeDescription: "在 SQL 編輯器中使用 Vim 風格的模態編輯", autoCloseBrackets: "自動成對補全", autoCloseBracketsDescription: "輸入左括號或左引號時自動補全對應的右括號或右引號", + sqlCompletionSection: "SQL 補全", + selectFirstCompletionOnOpen: "自動選取第一個補全候選", + selectFirstCompletionOnOpenDescription: "開啟後,候選清單彈出時會選取第一項,可用 Enter 或 Tab 接受;關閉後,Enter 換行,Tab 接受第一項。", completionTriggerMode: "自動補全觸發方式", completionTriggerModeDescription: "控制 SQL 補全彈窗何時自動開啟。僅手動提示需快捷鍵;輸入識別碼後提示需輸入至少一個字元;智慧位置提示保持目前行為。", completionTriggerModeManual: "僅手動提示", diff --git a/apps/desktop/src/lib/__tests__/editor/queryEditorCompletionKeymap.spec.ts b/apps/desktop/src/lib/__tests__/editor/queryEditorCompletionKeymap.spec.ts index 54f49f8ee4..47b5cc5d18 100644 --- a/apps/desktop/src/lib/__tests__/editor/queryEditorCompletionKeymap.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/queryEditorCompletionKeymap.spec.ts @@ -1,6 +1,7 @@ import { readFileSync } from "node:fs"; import ts from "typescript"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { acceptSelectedCompletionWithRetry, acceptSelectedOrFirstCompletion } from "@/lib/editor/queryEditorCompletionAcceptance"; import { DEFAULT_SHORTCUT_SETTINGS, normalizeShortcutSettings, shortcutToCodeMirrorKey } from "@/lib/editor/shortcutRegistry"; const queryEditorSource = readFileSync(new URL("../../../components/editor/QueryEditor.vue", import.meta.url), "utf8"); @@ -47,6 +48,7 @@ interface MockView { interface TabHarness { handleTab: (view: MockView) => boolean; + handleEnter: (view: MockView) => boolean; insertNewlineWithoutCompletion: (view: MockView) => boolean; acceptCompletionOrNextSnippetField: (view: MockView) => boolean; clearPendingCompletionTab: () => void; @@ -56,24 +58,31 @@ interface TabHarness { function createHarness(options: { completionStatus: (state: MockState) => "active" | "pending" | null; acceptCompletion?: (view: MockView) => boolean; + selectedCompletionIndex?: (state: MockState) => number | null; + selectFirstCompletion?: (view: MockView) => boolean; closeCompletion?: (view: MockView) => boolean; insertNewlineKeepIndent?: (view: MockView) => boolean; nextSnippetField?: (view: MockView) => boolean; indentMore?: (view: MockView) => boolean; acceptCompletionShortcut?: string; + selectFirstCompletionOnOpen?: boolean; }): TabHarness { const source = [ extractDeclaration(/const COMPLETION_REMOTE_LATENCY_BUDGET_MS = \d+;/, "remote completion latency budget"), extractDeclaration(/const COMPLETION_DEBOUNCE_DELAY_MS = \d+;/, "completion debounce delay"), extractDeclaration(/const COMPLETION_TAB_RETRY_DELAY_MS = \d+;/, "completion retry delay"), extractDeclaration(/const COMPLETION_TAB_MAX_WAIT_MS = [^;]+;/, "completion wait timeout"), + extractDeclaration(/const COMPLETION_ENTER_MAX_WAIT_MS = \d+;/, "completion Enter wait timeout"), "let pendingCompletionTabTimer: ReturnType | null = null;", + "let cancelPendingCompletionEnter: (() => void) | null = null;", "let suppressNextSqlCompletionAutoStartUntil = 0;", extractFunction("editorIndentUnit"), extractFunction("handleTab"), extractFunction("tabKeyAcceptsCompletion"), extractFunction("handleTabWithoutAcceptingCompletion"), extractFunction("performNormalTab"), + extractFunction("handleEnter"), + extractFunction("clearPendingCompletionEnter"), extractFunction("insertNewlineWithoutCompletion"), extractFunction("acceptCompletionOrNextSnippetField"), extractFunction("clearPendingCompletionTab"), @@ -86,6 +95,10 @@ function createHarness(options: { const factory = new Function( "codeMirrorCompletionStatus", "codeMirrorAcceptCompletion", + "codeMirrorSelectedCompletionIndex", + "codeMirrorSelectFirstCompletion", + "acceptSelectedCompletionWithRetry", + "acceptSelectedOrFirstCompletion", "codeMirrorCloseCompletion", "codeMirrorInsertNewlineKeepIndent", "insertQueryEditorNewline", @@ -95,11 +108,15 @@ function createHarness(options: { "normalizeShortcutSettings", "shortcutToCodeMirrorKey", "props", - `${javascript}\nreturn { handleTab, insertNewlineWithoutCompletion, acceptCompletionOrNextSnippetField, clearPendingCompletionTab, consumeSqlCompletionAutoStartSuppression };`, + `${javascript}\nreturn { handleTab, handleEnter, insertNewlineWithoutCompletion, acceptCompletionOrNextSnippetField, clearPendingCompletionTab, consumeSqlCompletionAutoStartSuppression };`, ); return factory( options.completionStatus, options.acceptCompletion ?? (() => false), + options.selectedCompletionIndex ?? (() => 0), + options.selectFirstCompletion ?? (() => false), + acceptSelectedCompletionWithRetry, + acceptSelectedOrFirstCompletion, options.closeCompletion ?? (() => false), options.insertNewlineKeepIndent ?? (() => false), (view: MockView, fallback: ((view: MockView) => boolean) | null | undefined) => fallback?.(view) ?? false, @@ -109,6 +126,7 @@ function createHarness(options: { editorSettings: { sqlFormatter: { useTabs: false, tabWidth: 2 }, shortcuts: { ...DEFAULT_SHORTCUT_SETTINGS, acceptCompletion: options.acceptCompletionShortcut ?? DEFAULT_SHORTCUT_SETTINGS.acceptCompletion }, + selectFirstCompletionOnOpen: options.selectFirstCompletionOnOpen ?? false, }, }, normalizeShortcutSettings, @@ -156,6 +174,19 @@ describe("QueryEditor completion Tab keymap", () => { expect(harness.consumeSqlCompletionAutoStartSuppression()).toBe(false); }); + it("accepts the selected completion on Enter only in first-option mode", () => { + const acceptCompletion = vi.fn(() => true); + const closeCompletion = vi.fn(() => true); + const insertNewlineKeepIndent = vi.fn(() => true); + const harness = createHarness({ completionStatus: () => "active", acceptCompletion, closeCompletion, insertNewlineKeepIndent, selectFirstCompletionOnOpen: true }); + const view = createView(); + + expect(harness.handleEnter(view)).toBe(true); + expect(acceptCompletion).toHaveBeenCalledWith(view); + expect(closeCompletion).not.toHaveBeenCalled(); + expect(insertNewlineKeepIndent).not.toHaveBeenCalled(); + }); + it("does not suppress later completion when Enter cannot insert a newline", () => { const harness = createHarness({ completionStatus: () => null, insertNewlineKeepIndent: () => false }); diff --git a/apps/desktop/src/lib/__tests__/editor/queryEditorCompletionSelection.spec.ts b/apps/desktop/src/lib/__tests__/editor/queryEditorCompletionSelection.spec.ts index 5c0cc6f3cb..42ea886392 100644 --- a/apps/desktop/src/lib/__tests__/editor/queryEditorCompletionSelection.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/queryEditorCompletionSelection.spec.ts @@ -2,14 +2,16 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; -import { acceptCompletion, autocompletion, completionStatus, currentCompletions, selectedCompletionIndex, startCompletion } from "@codemirror/autocomplete"; -import { EditorState, Transaction } from "@codemirror/state"; +import { acceptCompletion, autocompletion, completionStatus, currentCompletions, moveCompletionSelection, selectedCompletionIndex, startCompletion } from "@codemirror/autocomplete"; +import { EditorState, Prec, Transaction } from "@codemirror/state"; +import { insertNewlineKeepIndent } from "@codemirror/commands"; import { EditorView, keymap, runScopeHandlers } from "@codemirror/view"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import { acceptSelectedCompletionWithRetry, acceptSelectedOrFirstCompletion } from "@/lib/editor/queryEditorCompletionAcceptance"; const queryEditorSource = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/editor/QueryEditor.vue"), "utf8"); -function createCompletionView(doc = "") { +function createCompletionView(doc = "", selectFirstCompletionOnOpen = false, interactionDelay: number | null = 0) { return new EditorView({ parent: document.createElement("div"), state: EditorState.create({ @@ -19,11 +21,36 @@ function createCompletionView(doc = "") { autocompletion({ activateOnTyping: true, activateOnTypingDelay: 0, - interactionDelay: 0, - selectOnOpen: false, + ...(interactionDelay === null ? {} : { interactionDelay }), + selectOnOpen: selectFirstCompletionOnOpen, override: [() => ({ from: 0, options: [{ label: "select" }, { label: "set" }] })], }), - keymap.of([{ key: "Tab", run: acceptCompletion }]), + Prec.highest( + keymap.of([ + { + key: "Tab", + run: (view) => acceptSelectedOrFirstCompletion(view, acceptCompletion, selectedCompletionIndex, moveCompletionSelection(true)), + }, + { + key: "Enter", + run: (view) => { + if (selectFirstCompletionOnOpen) { + const result = acceptSelectedCompletionWithRetry(view, { + completionStatus, + acceptCompletion, + selectedCompletionIndex, + selectFirstCompletion: moveCompletionSelection(true), + retryDelayMs: 16, + maxWaitMs: 125, + onUnavailable: () => insertNewlineKeepIndent(view), + }); + if (result.handled) return true; + } + return insertNewlineKeepIndent(view); + }, + }, + ]), + ), ], }), }); @@ -41,34 +68,84 @@ async function expectCompletionOpenWithoutSelection(view: EditorView) { describe("QueryEditor completion selection", () => { it("binds SQL completion to CodeMirror's unselected-on-open mode", () => { - expect(queryEditorSource).toMatch(/buildSqlCompletionExtension = \(\) =>\s+autocompletion\(\{\s+activateOnTyping: true,\s+selectOnOpen: false,/); + expect(queryEditorSource).toMatch(/selectOnOpen: settingsStore\.editorSettings\.selectFirstCompletionOnOpen/); }); - it("opens manual completion unselected until ArrowDown selects the first option", async () => { + it("opens manual completion unselected and accepts the first option with Tab", async () => { const view = createCompletionView(); expect(startCompletion(view)).toBe(true); await expectCompletionOpenWithoutSelection(view); - expect(press(view, "Tab")).toBe(false); - expect(view.state.doc.toString()).toBe(""); - - expect(press(view, "ArrowDown")).toBe(true); - expect(selectedCompletionIndex(view.state)).toBe(0); expect(press(view, "Tab")).toBe(true); expect(view.state.doc.toString()).toBe("select"); view.destroy(); }); + it("accepts an explicitly selected option instead of resetting to the first option", async () => { + const view = createCompletionView(); + + expect(startCompletion(view)).toBe(true); + await expectCompletionOpenWithoutSelection(view); + expect(press(view, "ArrowUp")).toBe(true); + expect(selectedCompletionIndex(view.state)).toBe(1); + expect(press(view, "Tab")).toBe(true); + expect(view.state.doc.toString()).toBe("set"); + view.destroy(); + }); + it("opens typing-triggered completion without selecting the first option", async () => { const view = createCompletionView(); view.dispatch({ changes: { from: 0, insert: "s" }, + selection: { anchor: 1 }, annotations: Transaction.userEvent.of("input.type"), }); await expectCompletionOpenWithoutSelection(view); - expect(press(view, "Tab")).toBe(false); - expect(view.state.doc.toString()).toBe("s"); + expect(press(view, "Tab")).toBe(true); + expect(view.state.doc.toString()).toBe("select"); view.destroy(); }); + + it("keeps Enter as a newline while no completion option is selected", async () => { + const view = createCompletionView(); + + expect(startCompletion(view)).toBe(true); + await expectCompletionOpenWithoutSelection(view); + expect(press(view, "Enter")).toBe(true); + expect(view.state.doc.toString()).toBe("\n"); + view.destroy(); + }); + + it("restores first-option selection and Enter acceptance when explicitly enabled", async () => { + const view = createCompletionView("", true); + + expect(startCompletion(view)).toBe(true); + await expect.poll(() => completionStatus(view.state)).toBe("active"); + expect(selectedCompletionIndex(view.state)).toBe(0); + expect(press(view, "Enter")).toBe(true); + expect(view.state.doc.toString()).toBe("select"); + view.destroy(); + }); + + it("waits through CodeMirror's default interaction delay before accepting Enter", async () => { + let now = 0; + const dateNow = vi.spyOn(Date, "now").mockImplementation(() => now); + const view = createCompletionView("", true, null); + + try { + expect(startCompletion(view)).toBe(true); + await expect.poll(() => completionStatus(view.state), { interval: 1 }).toBe("active"); + expect(selectedCompletionIndex(view.state)).toBe(0); + expect(acceptCompletion(view)).toBe(false); + expect(press(view, "Enter")).toBe(true); + expect(view.state.doc.toString()).toBe(""); + + now = 100; + await expect.poll(() => view.state.doc.toString(), { interval: 10 }).toBe("select"); + } finally { + dateNow.mockRestore(); + view.destroy(); + } + }); }); diff --git a/apps/desktop/src/lib/editor/queryEditorCompletionAcceptance.ts b/apps/desktop/src/lib/editor/queryEditorCompletionAcceptance.ts new file mode 100644 index 0000000000..f67abacd78 --- /dev/null +++ b/apps/desktop/src/lib/editor/queryEditorCompletionAcceptance.ts @@ -0,0 +1,77 @@ +import type { EditorState } from "@codemirror/state"; +import type { Command, EditorView } from "@codemirror/view"; + +type SelectedCompletionIndex = (state: EditorState) => number | null; +type CompletionStatus = (state: EditorState) => "active" | "pending" | null; + +interface RetryCompletionAcceptanceOptions { + completionStatus: CompletionStatus; + acceptCompletion: Command | null; + selectedCompletionIndex: SelectedCompletionIndex | null; + selectFirstCompletion: Command | null; + retryDelayMs: number; + maxWaitMs: number; + onUnavailable: () => void; + onSettled?: () => void; +} + +interface CompletionAcceptanceAttempt { + handled: boolean; + cancel?: () => void; +} + +export function acceptSelectedOrFirstCompletion(view: EditorView, acceptCompletion: Command | null, selectedCompletionIndex: SelectedCompletionIndex | null, selectFirstCompletion: Command | null): boolean { + if (!acceptCompletion) return false; + if (!selectedCompletionIndex || !selectFirstCompletion) return acceptCompletion(view); + if (selectedCompletionIndex(view.state) !== null) return acceptCompletion(view); + if (!selectFirstCompletion(view)) return false; + return acceptCompletion(view); +} + +export function acceptSelectedCompletionWithRetry(view: EditorView, options: RetryCompletionAcceptanceOptions): CompletionAcceptanceAttempt { + if (options.completionStatus(view.state) !== "active") return { handled: false }; + if (acceptSelectedOrFirstCompletion(view, options.acceptCompletion, options.selectedCompletionIndex, options.selectFirstCompletion)) return { handled: true }; + + const initialDoc = view.state.doc; + const initialSelectionRanges = view.state.selection.ranges.map((range) => ({ anchor: range.anchor, head: range.head })); + const startedAt = Date.now(); + let timer: ReturnType | null = null; + let settled = false; + + const settle = () => { + if (settled) return; + settled = true; + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + options.onSettled?.(); + }; + + const retry = () => { + timer = null; + if (settled) return; + + const selectionRanges = view.state.selection.ranges; + if (view.state.doc !== initialDoc || selectionRanges.length !== initialSelectionRanges.length || selectionRanges.some((range, index) => range.anchor !== initialSelectionRanges[index]?.anchor || range.head !== initialSelectionRanges[index]?.head)) { + settle(); + return; + } + + const completionStatus = options.completionStatus(view.state); + if (completionStatus === "active" && acceptSelectedOrFirstCompletion(view, options.acceptCompletion, options.selectedCompletionIndex, options.selectFirstCompletion)) { + settle(); + return; + } + if (completionStatus && Date.now() - startedAt < options.maxWaitMs) { + timer = setTimeout(retry, options.retryDelayMs); + return; + } + + settle(); + options.onUnavailable(); + }; + + timer = setTimeout(retry, options.retryDelayMs); + return { handled: true, cancel: settle }; +} diff --git a/apps/desktop/src/lib/settings/editorSettingsDraft.ts b/apps/desktop/src/lib/settings/editorSettingsDraft.ts index 5b725abc76..335d7d6339 100644 --- a/apps/desktop/src/lib/settings/editorSettingsDraft.ts +++ b/apps/desktop/src/lib/settings/editorSettingsDraft.ts @@ -22,6 +22,7 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [ "autoAliasTables", "insertSpaceAfterCompletion", "sortCompletionColumnsAlphabetically", + "selectFirstCompletionOnOpen", "wordWrap", "vimModeEnabled", "autoCloseBrackets", diff --git a/apps/desktop/src/stores/__tests__/settingsStore.spec.ts b/apps/desktop/src/stores/__tests__/settingsStore.spec.ts index 12d55ea81d..c357564197 100644 --- a/apps/desktop/src/stores/__tests__/settingsStore.spec.ts +++ b/apps/desktop/src/stores/__tests__/settingsStore.spec.ts @@ -132,6 +132,13 @@ describe("normalizeEditorSettings", () => { expect(normalizeEditorSettings({ insertSpaceAfterCompletion: false }).insertSpaceAfterCompletion).toBe(false); }); + it("keeps non-intrusive completion selection as the default", () => { + expect(normalizeEditorSettings({}).selectFirstCompletionOnOpen).toBe(false); + expect(normalizeEditorSettings({ selectFirstCompletionOnOpen: true }).selectFirstCompletionOnOpen).toBe(true); + expect(normalizeEditorSettings({ selectFirstCompletionOnOpen: false }).selectFirstCompletionOnOpen).toBe(false); + expect(normalizeEditorSettings({ selectFirstCompletionOnOpen: "true" } as any).selectFirstCompletionOnOpen).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 970a2e7bde..7978542917 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -531,6 +531,7 @@ export interface EditorSettings { autoAliasTables: boolean; insertSpaceAfterCompletion: boolean; sortCompletionColumnsAlphabetically: boolean; + selectFirstCompletionOnOpen: boolean; wordWrap: boolean; tableDdlWordWrap: boolean; vimModeEnabled: boolean; @@ -742,6 +743,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = { autoAliasTables: true, insertSpaceAfterCompletion: true, sortCompletionColumnsAlphabetically: true, + selectFirstCompletionOnOpen: false, wordWrap: false, tableDdlWordWrap: true, vimModeEnabled: false, @@ -1117,6 +1119,7 @@ export function normalizeEditorSettings(settings: Partial, exist 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, + selectFirstCompletionOnOpen: typeof settings.selectFirstCompletionOnOpen === "boolean" ? settings.selectFirstCompletionOnOpen : DEFAULT_EDITOR_SETTINGS.selectFirstCompletionOnOpen, wordWrap: settings.wordWrap ?? DEFAULT_EDITOR_SETTINGS.wordWrap, tableDdlWordWrap: typeof settings.tableDdlWordWrap === "boolean" ? settings.tableDdlWordWrap : DEFAULT_EDITOR_SETTINGS.tableDdlWordWrap, vimModeEnabled: typeof settings.vimModeEnabled === "boolean" ? settings.vimModeEnabled : DEFAULT_EDITOR_SETTINGS.vimModeEnabled, @@ -1720,6 +1723,7 @@ export const useSettingsStore = defineStore("settings", () => { 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.selectFirstCompletionOnOpen !== undefined) editorSettings.value.selectFirstCompletionOnOpen = partial.selectFirstCompletionOnOpen === true; if (partial.wordWrap !== undefined) editorSettings.value.wordWrap = partial.wordWrap; if (partial.tableDdlWordWrap !== undefined) editorSettings.value.tableDdlWordWrap = partial.tableDdlWordWrap === true; if (partial.vimModeEnabled !== undefined) editorSettings.value.vimModeEnabled = partial.vimModeEnabled === true; diff --git a/packages/app-tests/editorSettingsDraft.test.ts b/packages/app-tests/editorSettingsDraft.test.ts index 2897a74a02..0371c20a3c 100644 --- a/packages/app-tests/editorSettingsDraft.test.ts +++ b/packages/app-tests/editorSettingsDraft.test.ts @@ -48,3 +48,11 @@ test("round-trips completion column ordering through the editor settings draft", assert.deepEqual(patch, { sortCompletionColumnsAlphabetically: false }); assert.equal(editorSettingsDraftFromSettings({ ...DEFAULT_EDITOR_SETTINGS, ...patch }).sortCompletionColumnsAlphabetically, false); }); + +test("round-trips completion selection behavior through the editor settings draft", () => { + const base = editorSettingsDraftFromSettings(DEFAULT_EDITOR_SETTINGS); + const patch = editorSettingsPatchFromDraft({ ...base, selectFirstCompletionOnOpen: true }, base); + + assert.deepEqual(patch, { selectFirstCompletionOnOpen: true }); + assert.equal(editorSettingsDraftFromSettings({ ...DEFAULT_EDITOR_SETTINGS, ...patch }).selectFirstCompletionOnOpen, true); +});