diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index 311d04399e..58a47d6348 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -253,6 +253,7 @@ import { import { applyEditorSettingsDraftToRefs, type EditorSettingsDraftRefMap } from "@/lib/settings/applyEditorSettingsDraft"; import { serializeSettingsTransfer, sortTransferCategories, transferCategoryForKey, type SettingsTransferCategoryId } from "@/lib/settings/settingsTransfer"; import { useConnectionStore } from "@/stores/connectionStore"; +import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; import { useSavedSqlStore } from "@/stores/savedSqlStore"; import { usePromptTemplateStore } from "@/stores/promptTemplateStore"; import { useTunnelProfileStore } from "@/stores/tunnelProfileStore"; @@ -291,6 +292,7 @@ const { t, locale } = useI18n(); const { toast } = useToast(); const settingsStore = useSettingsStore(); const connectionStore = useConnectionStore(); +const hasSqlServerConnection = computed(() => connectionStore.connections.some((connection) => effectiveDatabaseTypeForConnection(connection) === "sqlserver")); const savedSqlStore = useSavedSqlStore(); const promptTemplateStore = usePromptTemplateStore(); const tunnelProfileStore = useTunnelProfileStore(); @@ -568,6 +570,8 @@ 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 editSqlServerSpaceConfirmsCompletion = ref(settingsStore.editorSettings.sqlServerSpaceConfirmsCompletion); +const showSqlServerSpaceConfirmsCompletion = computed(() => hasSqlServerConnection.value || settingsStore.editorSettings.sqlServerSpaceConfirmsCompletion || editSqlServerSpaceConfirmsCompletion.value); const editSortCompletionColumnsAlphabetically = ref(settingsStore.editorSettings.sortCompletionColumnsAlphabetically); const editSelectFirstCompletionOnOpen = ref(settingsStore.editorSettings.selectFirstCompletionOnOpen); const editCompletionTriggerMode = ref(settingsStore.editorSettings.completionTriggerMode); @@ -904,6 +908,7 @@ function currentEditorSettingsDraft(): EditorSettingsDraft { showInsertValueHints: editShowInsertValueHints.value, autoAliasTables: editAutoAliasTables.value, insertSpaceAfterCompletion: editInsertSpaceAfterCompletion.value, + sqlServerSpaceConfirmsCompletion: editSqlServerSpaceConfirmsCompletion.value, sortCompletionColumnsAlphabetically: editSortCompletionColumnsAlphabetically.value, selectFirstCompletionOnOpen: editSelectFirstCompletionOnOpen.value, completionTriggerMode: editCompletionTriggerMode.value, @@ -1541,6 +1546,7 @@ function syncEditorSettingsDraftFromStore() { editShowInsertValueHints.value = settingsStore.editorSettings.showInsertValueHints; editAutoAliasTables.value = settingsStore.editorSettings.autoAliasTables; editInsertSpaceAfterCompletion.value = settingsStore.editorSettings.insertSpaceAfterCompletion; + editSqlServerSpaceConfirmsCompletion.value = settingsStore.editorSettings.sqlServerSpaceConfirmsCompletion; editSortCompletionColumnsAlphabetically.value = settingsStore.editorSettings.sortCompletionColumnsAlphabetically; editSelectFirstCompletionOnOpen.value = settingsStore.editorSettings.selectFirstCompletionOnOpen; editCompletionTriggerMode.value = settingsStore.editorSettings.completionTriggerMode; @@ -1671,6 +1677,7 @@ const editorSettingsDraftRefs: EditorSettingsDraftRefMap = { showInsertValueHints: editShowInsertValueHints, autoAliasTables: editAutoAliasTables, insertSpaceAfterCompletion: editInsertSpaceAfterCompletion, + sqlServerSpaceConfirmsCompletion: editSqlServerSpaceConfirmsCompletion, sortCompletionColumnsAlphabetically: editSortCompletionColumnsAlphabetically, selectFirstCompletionOnOpen: editSelectFirstCompletionOnOpen, wordWrap: editWordWrap, @@ -2114,6 +2121,7 @@ function resetDefaultsForTab(tab: SettingsCategory) { editShowInsertValueHints.value = DEFAULT_EDITOR_SETTINGS.showInsertValueHints; editAutoAliasTables.value = DEFAULT_EDITOR_SETTINGS.autoAliasTables; editInsertSpaceAfterCompletion.value = DEFAULT_EDITOR_SETTINGS.insertSpaceAfterCompletion; + editSqlServerSpaceConfirmsCompletion.value = DEFAULT_EDITOR_SETTINGS.sqlServerSpaceConfirmsCompletion; editSortCompletionColumnsAlphabetically.value = DEFAULT_EDITOR_SETTINGS.sortCompletionColumnsAlphabetically; editSelectFirstCompletionOnOpen.value = DEFAULT_EDITOR_SETTINGS.selectFirstCompletionOnOpen; editCompletionTriggerMode.value = DEFAULT_EDITOR_SETTINGS.completionTriggerMode; @@ -2259,6 +2267,7 @@ function resetAllDefaults() { editShowInsertValueHints.value = DEFAULT_EDITOR_SETTINGS.showInsertValueHints; editAutoAliasTables.value = DEFAULT_EDITOR_SETTINGS.autoAliasTables; editInsertSpaceAfterCompletion.value = DEFAULT_EDITOR_SETTINGS.insertSpaceAfterCompletion; + editSqlServerSpaceConfirmsCompletion.value = DEFAULT_EDITOR_SETTINGS.sqlServerSpaceConfirmsCompletion; editSortCompletionColumnsAlphabetically.value = DEFAULT_EDITOR_SETTINGS.sortCompletionColumnsAlphabetically; editSelectFirstCompletionOnOpen.value = DEFAULT_EDITOR_SETTINGS.selectFirstCompletionOnOpen; editWordWrap.value = DEFAULT_EDITOR_SETTINGS.wordWrap; @@ -2792,6 +2801,8 @@ const settingsSearchEntries = computed(() => [...SETTINGS_SEARCH_DEFINITIONS, ...createShortcutSettingsSearchDefinitions(SHORTCUT_DEFINITIONS)], { isWeb, + hasSqlServerConnection: hasSqlServerConnection.value, + sqlServerSpaceConfirmsCompletionEnabled: settingsStore.editorSettings.sqlServerSpaceConfirmsCompletion || editSqlServerSpaceConfirmsCompletion.value, visibleCategories: new Set(settingsCategoryNav.value.map((category) => category.value)), }, translateWithExecuteShortcut, @@ -6044,6 +6055,16 @@ onUnmounted(() => { +
+
+ +

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

+
+ +
+
diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 126b2cb671..7a2482d591 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -2705,6 +2705,38 @@ function acceptCompletionOrNextSnippetField(view: EditorViewType): boolean { return codeMirrorNextSnippetField?.(view) ?? false; } +function acceptSqlServerCompletionOnSpace(view: EditorViewType): boolean { + if (isEditorComposing(view)) return false; + if (props.databaseType !== "sqlserver" || !settingsStore.editorSettings.sqlServerSpaceConfirmsCompletion) return false; + if (codeMirrorCompletionStatus?.(view.state) !== "active") return false; + // A non-empty selection belongs to block editing, not word completion. + if (!view.state.selection.main.empty) return false; + const selected = codeMirrorSelectedCompletion?.(view.state) as QueryCompletionOption | null | undefined; + const completionType = selected?.type; + if (completionType !== "keyword" && completionType !== "table" && completionType !== "column") return false; + // Batch-selection rows own Space for checkbox toggling; this Prec.highest binding outranks their keymap. + if (selected?.dbxBatchColumnSelection || selected?.dbxBatchColumnSelectionAction) return false; + if (!(codeMirrorAcceptCompletion?.(view) ?? false)) return false; + + const selection = view.state.selection.main; + if (!selection.empty) return true; + const cursor = selection.head; + const previousCharacter = cursor > 0 ? view.state.sliceDoc(cursor - 1, cursor) : ""; + if (/\s/.test(previousCharacter)) return true; + + const nextCharacter = view.state.sliceDoc(cursor, cursor + 1); + if (/\s/.test(nextCharacter)) { + view.dispatch({ selection: { anchor: cursor + 1 }, scrollIntoView: true }); + } else { + view.dispatch({ + changes: { from: cursor, insert: " " }, + selection: { anchor: cursor + 1 }, + scrollIntoView: true, + }); + } + return true; +} + function clearPendingCompletionTab() { if (pendingCompletionTabTimer === null) return; clearTimeout(pendingCompletionTabTimer); @@ -6942,6 +6974,7 @@ onMounted(async () => { vimModeComp.of(vimModeExtension(initialSettings.vimModeEnabled)), defaultKeymapComp.of(defaultKeymapExtension()), keymap.of([...searchKeymapWithoutModD(searchKeymap), ...historyKeymap, ...foldKeymap, ...completionKeymap]), + Prec.highest(keymap.of([{ key: "Space", run: acceptSqlServerCompletionOnSpace }])), sqlLanguageComp.of(buildSqlLanguageExtension()), sqlSemanticHighlightComp.of(buildSqlSemanticHighlightExtension()), tooltips({ parent: tooltipParent }), diff --git a/apps/desktop/src/i18n/locales/az.ts b/apps/desktop/src/i18n/locales/az.ts index d6b93e6baa..74e71104e0 100644 --- a/apps/desktop/src/i18n/locales/az.ts +++ b/apps/desktop/src/i18n/locales/az.ts @@ -7434,6 +7434,8 @@ export default withEnglishFallback({ sqlVariableSyntax_atSetDescription: "İşlətməzdən əvvəl sətirdaxili dəyişən elanlarını aç və dəyərlərini yerinə qoy.", autoAliasTables: "Cədvəl ləqəblərini avtomatik əlavə et", autoAliasTablesDescription: "FROM və ya JOIN daxilində cədvəl tamamlaması seçilərkən order_items AS oi kimi yaradılmış ləqəb daxil et.", + sqlServerSpaceConfirmsCompletion: "Boşluqla tamamlamanı təsdiqlə (SQL Server üslubu)", + sqlServerSpaceConfirmsCompletionDescription: "Tamamlama siyahısı açıqkən boşluq düyməsini basmaq seçilmiş açar sözü, cədvəli və ya sütunu təsdiqləyir və boşluğu saxlayır. Enter və Tab əvvəlki kimi işləyir.", redisKeyTemplatesSection: "Redis", redisKeyTemplates: "Açar axtarışı şablonları", redisKeyTemplatesPlaceholder: "Nümunə:\nuser:{'{'}$userId{'}'}\nv3:adui:{'{'}$positionID{'}'}", diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 22fc737234..ea593b0ceb 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -7662,6 +7662,8 @@ export default { sqlVariableSyntax_atSetDescription: "Expand inline variable declarations and substitute their values before running.", autoAliasTables: "Automatically add table aliases", autoAliasTablesDescription: "When selecting a table completion in FROM or JOIN, insert a generated alias such as order_items AS oi.", + sqlServerSpaceConfirmsCompletion: "Space confirms completion (SQL Server style)", + sqlServerSpaceConfirmsCompletionDescription: "While the completion list is open, pressing Space accepts the selected keyword, table, or column and keeps the trailing space. Enter and Tab behave as before.", redisKeyTemplatesSection: "Redis", redisKeyTemplates: "Key search templates", redisKeyTemplatesPlaceholder: "Example:\nuser:{'{'}$userId{'}'}\nv3:adui:{'{'}$positionID{'}'}", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index b3db5ba88b..50202033e6 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -7174,6 +7174,8 @@ export default withEnglishFallback({ sqlVariableSyntax_atSetDescription: "Expande las declaraciones de variables en línea y sustituye sus valores antes de ejecutar.", autoAliasTables: "Agregar alias de tabla automáticamente", autoAliasTablesDescription: "Al seleccionar una tabla en FROM o JOIN, inserta un alias generado como order_items AS oi.", + sqlServerSpaceConfirmsCompletion: "Confirmar la sugerencia con espacio (estilo SQL Server)", + sqlServerSpaceConfirmsCompletionDescription: "Con la lista de sugerencias abierta, pulsar espacio acepta la palabra clave, tabla o columna seleccionada y conserva el espacio. Intro y Tabulador no cambian.", redisKeyTemplatesSection: "Redis", redisKeyTemplates: "Plantillas de búsqueda de claves", redisKeyTemplatesPlaceholder: "Example:\nuser:{'{'}$userId{'}'}\nv3:adui:{'{'}$positionID{'}'}", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 41654ec6e4..a8ac4806e3 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -7174,6 +7174,8 @@ export default withEnglishFallback({ sqlVariableSyntax_atSetDescription: "Espande le dichiarazioni di variabili inline e ne sostituisce i valori prima dell'esecuzione.", autoAliasTables: "Aggiungi alias tabella automaticamente", autoAliasTablesDescription: "Quando scegli una tabella in FROM o JOIN, inserisce un alias generato come order_items AS oi.", + sqlServerSpaceConfirmsCompletion: "Conferma il completamento con lo spazio (stile SQL Server)", + sqlServerSpaceConfirmsCompletionDescription: "Con l'elenco dei suggerimenti aperto, premendo spazio si accetta la parola chiave, la tabella o la colonna selezionata mantenendo lo spazio. Invio e Tab restano invariati.", redisKeyTemplatesSection: "Redis", redisKeyTemplates: "Modelli di ricerca chiavi", redisKeyTemplatesPlaceholder: "Example:\nuser:{'{'}$userId{'}'}\nv3:adui:{'{'}$positionID{'}'}", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index a9e0111cbc..7dc098b458 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -7187,6 +7187,8 @@ export default withEnglishFallback({ sqlVariableSyntax_atSetDescription: "実行前にインライン変数宣言を展開し、その値をインライン化します。", autoAliasTables: "テーブル別名を自動追加", autoAliasTablesDescription: "FROM または JOIN でテーブル補完を選択すると、order_items AS oi のような生成済み別名を挿入します。", + sqlServerSpaceConfirmsCompletion: "スペースで補完を確定(SQL Server 方式)", + sqlServerSpaceConfirmsCompletionDescription: "補完リストの表示中にスペースを押すと、選択中のキーワード・テーブル・列を確定し、空白を保持します。Enter と Tab は従来どおり動作します。", redisKeyTemplatesSection: "Redis", redisKeyTemplates: "キー検索テンプレート", redisKeyTemplatesPlaceholder: "Example:\nuser:{'{'}$userId{'}'}\nv3:adui:{'{'}$positionID{'}'}", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 1340fead5f..6bffeb1881 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -6940,6 +6940,8 @@ export default withEnglishFallback({ sqlVariableSyntax_atSetDescription: "실행 전에 인라인 변수 선언을 확장하고 값을 치환합니다.", autoAliasTables: "자동으로 테이블 별칭 추가", autoAliasTablesDescription: "FROM이나 JOIN에서 테이블 완성을 선택할 때 order_items AS oi 같은 생성된 별칭을 삽입합니다.", + sqlServerSpaceConfirmsCompletion: "공백으로 완성 확정(SQL Server 방식)", + sqlServerSpaceConfirmsCompletionDescription: "완성 목록이 열려 있을 때 공백을 누르면 선택한 키워드, 테이블 또는 컬럼을 확정하고 공백을 유지합니다. Enter와 Tab은 기존과 동일하게 동작합니다.", redisKeyTemplatesSection: "Redis", redisKeyTemplates: "키 검색 템플릿", redisKeyTemplatesPlaceholder: "Example:\nuser:{'{'}$userId{'}'}\nv3:adui:{'{'}$positionID{'}'}", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 9751ae5f1e..fbc2afe76b 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -7176,6 +7176,8 @@ export default withEnglishFallback({ sqlVariableSyntax_atSetDescription: "Expande as declarações de variáveis inline e substitui seus valores antes de executar.", autoAliasTables: "Adicionar alias de tabela automaticamente", autoAliasTablesDescription: "Ao escolher uma tabela em FROM ou JOIN, insere um alias gerado como order_items AS oi.", + sqlServerSpaceConfirmsCompletion: "Confirmar a conclusão com espaço (estilo SQL Server)", + sqlServerSpaceConfirmsCompletionDescription: "Com a lista de sugestões aberta, pressionar espaço aceita a palavra-chave, tabela ou coluna selecionada e mantém o espaço. Enter e Tab continuam iguais.", redisKeyTemplatesSection: "Redis", redisKeyTemplates: "Modelos de busca de chave", redisKeyTemplatesPlaceholder: "Example:\nuser:{'{'}$userId{'}'}\nv3:adui:{'{'}$positionID{'}'}", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index b9e6e33c21..2219e68acd 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -7315,6 +7315,8 @@ export default withEnglishFallback({ sqlVariableSyntax_atSetDescription: "Satır içi değişken bildirimlerini genişlet ve çalıştırmadan önce değerlerini yerine koy.", autoAliasTables: "Tablo takma adlarını otomatik ekle", autoAliasTablesDescription: "FROM veya JOIN içinde bir tablo tamamlaması seçildiğinde order_items AS oi gibi üretilmiş bir takma ad ekle.", + sqlServerSpaceConfirmsCompletion: "Boşlukla tamamlamayı onayla (SQL Server tarzı)", + sqlServerSpaceConfirmsCompletionDescription: "Tamamlama listesi açıkken boşluk tuşuna basmak seçili anahtar sözcüğü, tabloyu veya sütunu onaylar ve boşluğu korur. Enter ve Tab aynı kalır.", redisKeyTemplatesSection: "Redis", redisKeyTemplates: "Anahtar arama şablonları", redisKeyTemplatesPlaceholder: "Örnek:\nuser:{'{'}$userId{'}'}\nv3:adui:{'{'}$positionID{'}'}", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 136c75eb63..839033239a 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -7634,6 +7634,8 @@ export default withEnglishFallback({ sqlVariableSyntax_atSetDescription: "执行前展开内联变量声明并就地代入其值。", autoAliasTables: "自动添加表别名", autoAliasTablesDescription: "补全 FROM 或 JOIN 表名时自动添加别名。", + sqlServerSpaceConfirmsCompletion: "空格确认补全(SQL Server 习惯)", + sqlServerSpaceConfirmsCompletionDescription: "补全列表打开时,按空格直接采用选中的关键字、表名或列名,并保留空格。Enter 和 Tab 保持原有行为。", redisKeyTemplatesSection: "Redis", redisKeyTemplates: "Key 搜索模板", redisKeyTemplatesPlaceholder: "例如:\nuser:{'{'}$userId{'}'}\nv3:adui:{'{'}$positionID{'}'}", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index b32ba74d21..213663c60c 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -6499,6 +6499,8 @@ export default withEnglishFallback({ sqlVariableSyntax_atSetDescription: "執行前展開內聯變數宣告並就地代入其值。", autoAliasTables: "自動加入資料表別名", autoAliasTablesDescription: "在 FROM 或 JOIN 中選擇資料表補全時,自動插入類似 order_items AS oi 的資料表別名。", + sqlServerSpaceConfirmsCompletion: "空格確認補全(SQL Server 習慣)", + sqlServerSpaceConfirmsCompletionDescription: "補全清單開啟時,按空格直接採用選取的關鍵字、資料表或欄名,並保留空格。Enter 與 Tab 維持原本行為。", redisKeyTemplatesSection: "Redis", redisKeyTemplates: "Key 搜尋模板", redisKeyTemplatesPlaceholder: "例如:\nuser:{'{'}$userId{'}'}\nv3:adui:{'{'}$positionID{'}'}", diff --git a/apps/desktop/src/lib/__tests__/editor/queryEditorCompletionKeymap.spec.ts b/apps/desktop/src/lib/__tests__/editor/queryEditorCompletionKeymap.spec.ts index 2dde447556..d404a1583f 100644 --- a/apps/desktop/src/lib/__tests__/editor/queryEditorCompletionKeymap.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/queryEditorCompletionKeymap.spec.ts @@ -37,6 +37,7 @@ interface MockState { doc: { lineAt: (position: number) => { from: number; text: string }; }; + sliceDoc: (from: number, to: number) => string; selection: { main: MockSelection; ranges: MockSelection[] }; replaceSelection: ReturnType; update: ReturnType; @@ -52,12 +53,15 @@ interface TabHarness { handleEnter: (view: MockView) => boolean; insertNewlineWithoutCompletion: (view: MockView) => boolean; acceptCompletionOrNextSnippetField: (view: MockView) => boolean; + acceptSqlServerCompletionOnSpace: (view: MockView) => boolean; clearPendingCompletionTab: () => void; consumeSqlCompletionAutoStartSuppression: () => boolean; } function createHarness(options: { + databaseType?: string; completionStatus: (state: MockState) => "active" | "pending" | null; + selectedCompletion?: (state: MockState) => { type?: string } | null; acceptCompletion?: (view: MockView) => boolean; selectedCompletionIndex?: (state: MockState) => number | null; selectFirstCompletion?: (view: MockView) => boolean; @@ -68,6 +72,7 @@ function createHarness(options: { indentMore?: (view: MockView) => boolean; acceptCompletionShortcut?: string; selectFirstCompletionOnOpen?: boolean; + sqlServerSpaceConfirmsCompletion?: boolean; imeComposing?: (view: MockView) => boolean; }): TabHarness { const source = [ @@ -88,6 +93,7 @@ function createHarness(options: { extractFunction("clearPendingCompletionEnter"), extractFunction("insertNewlineWithoutCompletion"), extractFunction("acceptCompletionOrNextSnippetField"), + extractFunction("acceptSqlServerCompletionOnSpace"), extractFunction("clearPendingCompletionTab"), extractFunction("waitForCompletionTab"), extractFunction("consumeSqlCompletionAutoStartSuppression"), @@ -97,6 +103,7 @@ function createHarness(options: { }).outputText; const factory = new Function( "codeMirrorCompletionStatus", + "codeMirrorSelectedCompletion", "isBatchColumnSelectionCompletionActive", "codeMirrorAcceptCompletion", "codeMirrorSelectedCompletionIndex", @@ -114,10 +121,11 @@ function createHarness(options: { "shortcutToCodeMirrorKey", "props", "isEditorComposing", - `${javascript}\nreturn { handleTab, handleEnter, insertNewlineWithoutCompletion, acceptCompletionOrNextSnippetField, clearPendingCompletionTab, consumeSqlCompletionAutoStartSuppression };`, + `${javascript}\nreturn { handleTab, handleEnter, insertNewlineWithoutCompletion, acceptCompletionOrNextSnippetField, acceptSqlServerCompletionOnSpace, clearPendingCompletionTab, consumeSqlCompletionAutoStartSuppression };`, ); return factory( options.completionStatus, + options.selectedCompletion ?? (() => ({ type: "column" })), (status: "active" | "pending" | null) => status === "active", options.acceptCompletion ?? (() => false), options.selectedCompletionIndex ?? (() => 0), @@ -135,11 +143,12 @@ function createHarness(options: { sqlFormatter: { useTabs: false, tabWidth: 2 }, shortcuts: { ...DEFAULT_SHORTCUT_SETTINGS, acceptCompletion: options.acceptCompletionShortcut ?? DEFAULT_SHORTCUT_SETTINGS.acceptCompletion }, selectFirstCompletionOnOpen: options.selectFirstCompletionOnOpen ?? false, + sqlServerSpaceConfirmsCompletion: options.sqlServerSpaceConfirmsCompletion ?? true, }, }, normalizeShortcutSettings, shortcutToCodeMirrorKey, - { databaseType: "mysql" }, + { databaseType: options.databaseType ?? "mysql" }, options.imeComposing ?? ((view: MockView) => (view as MockView & { composing?: boolean }).composing === true || (view as MockView & { compositionStarted?: boolean }).compositionStarted === true), ) as TabHarness; } @@ -150,6 +159,7 @@ function createView(text = "SELECT", position = text.length, selectionOverrides: doc: { lineAt: () => ({ from: 0, text }), }, + sliceDoc: (from, to) => text.slice(from, to), selection: { main: selection, ranges: [selection, ...additionalRanges] }, replaceSelection: vi.fn((insert: string) => ({ insert })), update: vi.fn((change: unknown, options: unknown) => ({ change, options })), @@ -560,3 +570,77 @@ describe("QueryEditor completion Tab keymap", () => { expect(startCompletion).not.toHaveBeenCalled(); }); }); + +describe("QueryEditor SQL Server completion Space keymap", () => { + it.each(["keyword", "table", "column"])("accepts an active %s completion", (type) => { + const acceptCompletion = vi.fn(() => true); + const harness = createHarness({ + databaseType: "sqlserver", + completionStatus: () => "active", + selectedCompletion: () => ({ type }), + acceptCompletion, + }); + const view = createView("FXXX "); + + expect(harness.acceptSqlServerCompletionOnSpace(view)).toBe(true); + expect(acceptCompletion).toHaveBeenCalledWith(view); + expect(view.dispatch).not.toHaveBeenCalled(); + }); + + it("inserts the typed space when automatic completion spacing is disabled", () => { + const harness = createHarness({ databaseType: "sqlserver", completionStatus: () => "active", acceptCompletion: () => true }); + const view = createView("FXXX"); + + expect(harness.acceptSqlServerCompletionOnSpace(view)).toBe(true); + expect(view.dispatch).toHaveBeenCalledWith({ + changes: { from: 4, insert: " " }, + selection: { anchor: 5 }, + scrollIntoView: true, + }); + }); + + it("moves over an existing following space instead of duplicating it", () => { + const harness = createHarness({ databaseType: "sqlserver", completionStatus: () => "active", acceptCompletion: () => true }); + const view = createView("FXXX ", 4); + + expect(harness.acceptSqlServerCompletionOnSpace(view)).toBe(true); + expect(view.dispatch).toHaveBeenCalledWith({ selection: { anchor: 5 }, scrollIntoView: true }); + }); + + it.each([ + { databaseType: "postgresql", status: "active", completionType: "column" }, + { databaseType: "sqlserver", status: "pending", completionType: "column" }, + { databaseType: "sqlserver", status: "active", completionType: "function" }, + { databaseType: "sqlserver", status: "active", completionType: "snippet" }, + ])("keeps ordinary Space input for $databaseType/$status/$completionType", ({ databaseType, status, completionType }) => { + const acceptCompletion = vi.fn(() => true); + const harness = createHarness({ + databaseType, + completionStatus: () => status as "active" | "pending", + selectedCompletion: () => ({ type: completionType }), + acceptCompletion, + }); + + expect(harness.acceptSqlServerCompletionOnSpace(createView())).toBe(false); + expect(acceptCompletion).not.toHaveBeenCalled(); + }); + + it("keeps ordinary Space input when CodeMirror cannot accept the selected completion", () => { + const harness = createHarness({ databaseType: "sqlserver", completionStatus: () => "active", acceptCompletion: () => false }); + + expect(harness.acceptSqlServerCompletionOnSpace(createView())).toBe(false); + }); + + it("keeps ordinary Space input while the SQL Server space-confirm setting is disabled", () => { + const acceptCompletion = vi.fn(() => true); + const harness = createHarness({ + databaseType: "sqlserver", + completionStatus: () => "active", + acceptCompletion, + sqlServerSpaceConfirmsCompletion: false, + }); + + expect(harness.acceptSqlServerCompletionOnSpace(createView())).toBe(false); + expect(acceptCompletion).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/lib/settings/__tests__/settingsSearch.spec.ts b/apps/desktop/src/lib/settings/__tests__/settingsSearch.spec.ts index 8ca86efb6d..c32d86276a 100644 --- a/apps/desktop/src/lib/settings/__tests__/settingsSearch.spec.ts +++ b/apps/desktop/src/lib/settings/__tests__/settingsSearch.spec.ts @@ -72,6 +72,32 @@ describe("settings search", () => { expect(searchSettings(entries, "line number", "en").map((entry) => entry.id)).toEqual(["editor-line-numbers"]); }); + it("indexes SQL Server space confirmation when SQL Server exists or an exported setting is enabled", () => { + const definition = SETTINGS_SEARCH_DEFINITIONS.find((entry) => entry.id === "editor-sqlserver-space-completion"); + expect(definition).toEqual( + expect.objectContaining({ + id: "editor-sqlserver-space-completion", + category: "editor", + titleKey: "settings.sqlServerSpaceConfirmsCompletion", + descriptionKey: "settings.sqlServerSpaceConfirmsCompletionDescription", + targetId: "editor", + visible: expect.any(Function), + }), + ); + + const context = { isWeb: false, visibleCategories: new Set(["editor"]) }; + const withoutSqlServer = resolveSettingsSearchEntries(SETTINGS_SEARCH_DEFINITIONS, { ...context, hasSqlServerConnection: false, sqlServerSpaceConfirmsCompletionEnabled: false }, translate, categoryLabels); + const importedWithoutSqlServer = resolveSettingsSearchEntries(SETTINGS_SEARCH_DEFINITIONS, { ...context, hasSqlServerConnection: false, sqlServerSpaceConfirmsCompletionEnabled: true }, translate, categoryLabels); + const withSqlServer = resolveSettingsSearchEntries(SETTINGS_SEARCH_DEFINITIONS, { ...context, hasSqlServerConnection: true, sqlServerSpaceConfirmsCompletionEnabled: false }, translate, categoryLabels); + + expect(withoutSqlServer.map((entry) => entry.id)).not.toContain("editor-sqlserver-space-completion"); + expect(importedWithoutSqlServer.map((entry) => entry.id)).toContain("editor-sqlserver-space-completion"); + expect(withSqlServer.map((entry) => entry.id)).toContain("editor-sqlserver-space-completion"); + expect(settingsDialogSource).toContain('const hasSqlServerConnection = computed(() => connectionStore.connections.some((connection) => effectiveDatabaseTypeForConnection(connection) === "sqlserver"));'); + expect(settingsDialogSource).toContain("const showSqlServerSpaceConfirmsCompletion = computed("); + expect(settingsDialogSource).toContain('
'); + }); + it("does not index connection or query timeout under editor settings", () => { expect(SETTINGS_SEARCH_DEFINITIONS.map((definition) => definition.id)).not.toContain("editor-global-connect-timeout"); expect(SETTINGS_SEARCH_DEFINITIONS.map((definition) => definition.id)).not.toContain("editor-global-query-timeout"); diff --git a/apps/desktop/src/lib/settings/editorSettingsDraft.ts b/apps/desktop/src/lib/settings/editorSettingsDraft.ts index f3875d6eaa..3d700819d8 100644 --- a/apps/desktop/src/lib/settings/editorSettingsDraft.ts +++ b/apps/desktop/src/lib/settings/editorSettingsDraft.ts @@ -26,6 +26,7 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [ "showInsertValueHints", "autoAliasTables", "insertSpaceAfterCompletion", + "sqlServerSpaceConfirmsCompletion", "sortCompletionColumnsAlphabetically", "selectFirstCompletionOnOpen", "wordWrap", diff --git a/apps/desktop/src/lib/settings/settingsSearch.ts b/apps/desktop/src/lib/settings/settingsSearch.ts index edf92c26dd..0874ec8a02 100644 --- a/apps/desktop/src/lib/settings/settingsSearch.ts +++ b/apps/desktop/src/lib/settings/settingsSearch.ts @@ -13,6 +13,8 @@ export function resolveSettingsCategory(initialTab?: string): SettingsCategory { export interface SettingsSearchContext { isWeb: boolean; + hasSqlServerConnection?: boolean; + sqlServerSpaceConfirmsCompletionEnabled?: boolean; visibleCategories: ReadonlySet; } @@ -88,6 +90,7 @@ export function createToolbarVisibilitySettingsSearchDefinitions(items: readonly const desktopOnly = (context: SettingsSearchContext) => !context.isWeb; const webOnly = (context: SettingsSearchContext) => context.isWeb; +const sqlServerSpaceCompletionVisible = (context: SettingsSearchContext) => context.hasSqlServerConnection === true || context.sqlServerSpaceConfirmsCompletionEnabled === true; export interface ShortcutSearchDefinitionSource { id: string; @@ -129,6 +132,7 @@ export const SETTINGS_SEARCH_DEFINITIONS: readonly SettingsSearchDefinition[] = { id: "editor-vim", category: "editor", titleKey: "settings.vimMode", descriptionKey: "settings.vimModeDescription", targetId: "editor" }, { id: "editor-brackets", category: "editor", titleKey: "settings.autoCloseBrackets", descriptionKey: "settings.autoCloseBracketsDescription", targetId: "editor" }, { id: "editor-completion-spacing", category: "editor", titleKey: "settings.insertSpaceAfterCompletion", descriptionKey: "settings.insertSpaceAfterCompletionDescription", targetId: "editor" }, + { id: "editor-sqlserver-space-completion", category: "editor", titleKey: "settings.sqlServerSpaceConfirmsCompletion", descriptionKey: "settings.sqlServerSpaceConfirmsCompletionDescription", targetId: "editor", visible: sqlServerSpaceCompletionVisible }, { id: "editor-completion-trigger-mode", category: "editor", titleKey: "settings.completionTriggerMode", descriptionKey: "settings.completionTriggerModeDescription", targetId: "editor" }, { id: "editor-auto-alias", category: "editor", titleKey: "settings.autoAliasTables", descriptionKey: "settings.autoAliasTablesDescription", targetId: "editor" }, { id: "editor-unsaved-close", category: "editor", titleKey: "settings.confirmUnsavedSqlClose", descriptionKey: "settings.confirmUnsavedSqlCloseDescription", targetId: "editor" }, diff --git a/apps/desktop/src/lib/settings/settingsTransfer.ts b/apps/desktop/src/lib/settings/settingsTransfer.ts index 2d81bbb7aa..20dbdfb729 100644 --- a/apps/desktop/src/lib/settings/settingsTransfer.ts +++ b/apps/desktop/src/lib/settings/settingsTransfer.ts @@ -83,6 +83,7 @@ const SETTINGS_TRANSFER_CATEGORY_KEYS: Record { expect(normalizeEditorSettings({ insertSpaceAfterCompletion: false }).insertSpaceAfterCompletion).toBe(false); }); + it("keeps SQL Server space-confirm completion off by default and preserves an explicit opt-in", () => { + expect(normalizeEditorSettings({}).sqlServerSpaceConfirmsCompletion).toBe(false); + expect(normalizeEditorSettings({ sqlServerSpaceConfirmsCompletion: true }).sqlServerSpaceConfirmsCompletion).toBe(true); + expect(normalizeEditorSettings({ sqlServerSpaceConfirmsCompletion: "yes" as unknown as boolean }).sqlServerSpaceConfirmsCompletion).toBe(false); + }); + it("selects the first completion candidate by default and preserves the opt-out", () => { expect(normalizeEditorSettings({}).selectFirstCompletionOnOpen).toBe(true); expect(normalizeEditorSettings({ selectFirstCompletionOnOpen: true }).selectFirstCompletionOnOpen).toBe(true); diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index 926cc96ae1..7282ebb153 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -771,6 +771,7 @@ export interface EditorSettings { showInsertValueHints: boolean; autoAliasTables: boolean; insertSpaceAfterCompletion: boolean; + sqlServerSpaceConfirmsCompletion: boolean; sortCompletionColumnsAlphabetically: boolean; selectFirstCompletionOnOpen: boolean; wordWrap: boolean; @@ -1028,6 +1029,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = { showInsertValueHints: true, autoAliasTables: true, insertSpaceAfterCompletion: true, + sqlServerSpaceConfirmsCompletion: false, sortCompletionColumnsAlphabetically: true, selectFirstCompletionOnOpen: true, wordWrap: false, @@ -1532,6 +1534,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, + sqlServerSpaceConfirmsCompletion: typeof settings.sqlServerSpaceConfirmsCompletion === "boolean" ? settings.sqlServerSpaceConfirmsCompletion : DEFAULT_EDITOR_SETTINGS.sqlServerSpaceConfirmsCompletion, 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, @@ -2308,6 +2311,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.sqlServerSpaceConfirmsCompletion !== undefined) editorSettings.value.sqlServerSpaceConfirmsCompletion = partial.sqlServerSpaceConfirmsCompletion === 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; diff --git a/docs/screenshots/issue-2838-settings-toggle.png b/docs/screenshots/issue-2838-settings-toggle.png new file mode 100644 index 0000000000..cfca81b2e6 Binary files /dev/null and b/docs/screenshots/issue-2838-settings-toggle.png differ diff --git a/docs/screenshots/issue-2838-space-off-after.png b/docs/screenshots/issue-2838-space-off-after.png new file mode 100644 index 0000000000..569cbe6cbd Binary files /dev/null and b/docs/screenshots/issue-2838-space-off-after.png differ diff --git a/docs/screenshots/issue-2838-space-on-after.png b/docs/screenshots/issue-2838-space-on-after.png new file mode 100644 index 0000000000..49639790c6 Binary files /dev/null and b/docs/screenshots/issue-2838-space-on-after.png differ diff --git a/docs/screenshots/issue-2838-space-on-before.png b/docs/screenshots/issue-2838-space-on-before.png new file mode 100644 index 0000000000..cd9cafc84d Binary files /dev/null and b/docs/screenshots/issue-2838-space-on-before.png differ diff --git a/docs/screenshots/issue-2838-table-scoped-columns.png b/docs/screenshots/issue-2838-table-scoped-columns.png new file mode 100644 index 0000000000..3c27321adb Binary files /dev/null and b/docs/screenshots/issue-2838-table-scoped-columns.png differ