diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 2a32c5833b..5ff5ae97ba 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -748,7 +748,25 @@ function editorIndentUnit(): string { } function handleTab(view: EditorViewType): boolean { - return acceptCompletionOrNextSnippetField(view) || performNormalTab(view); + if (tabKeyAcceptsCompletion()) { + return acceptCompletionOrNextSnippetField(view) || performNormalTab(view); + } + return handleTabWithoutAcceptingCompletion(view) || performNormalTab(view); +} + +// The Tab key is always wired up for indentation and snippet-field navigation, +// but it must only accept an open completion popup when the user's configured +// "accept completion" shortcut is actually Tab — otherwise a user who remapped +// that shortcut (e.g. to Enter) would find Tab silently accepting completions +// anyway, ignoring their setting (dbx#6236). +function tabKeyAcceptsCompletion(): boolean { + const shortcuts = normalizeShortcutSettings(settingsStore.editorSettings.shortcuts); + return shortcutToCodeMirrorKey(shortcuts.acceptCompletion) === "Tab"; +} + +function handleTabWithoutAcceptingCompletion(view: EditorViewType): boolean { + if (codeMirrorCompletionStatus?.(view.state)) return false; + return codeMirrorNextSnippetField?.(view) ?? false; } function performNormalTab(view: EditorViewType): boolean { diff --git a/apps/desktop/src/lib/__tests__/editor/queryEditorCompletionKeymap.spec.ts b/apps/desktop/src/lib/__tests__/editor/queryEditorCompletionKeymap.spec.ts index 8c72204814..628441da5f 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 { DEFAULT_SHORTCUT_SETTINGS, normalizeShortcutSettings, shortcutToCodeMirrorKey } from "@/lib/editor/shortcutRegistry"; const queryEditorSource = readFileSync(new URL("../../../components/editor/QueryEditor.vue", import.meta.url), "utf8"); @@ -59,6 +60,7 @@ function createHarness(options: { insertNewlineKeepIndent?: (view: MockView) => boolean; nextSnippetField?: (view: MockView) => boolean; indentMore?: (view: MockView) => boolean; + acceptCompletionShortcut?: string; }): TabHarness { const source = [ extractDeclaration(/const COMPLETION_REMOTE_LATENCY_BUDGET_MS = \d+;/, "remote completion latency budget"), @@ -69,6 +71,8 @@ function createHarness(options: { "let suppressNextSqlCompletionAutoStartUntil = 0;", extractFunction("editorIndentUnit"), extractFunction("handleTab"), + extractFunction("tabKeyAcceptsCompletion"), + extractFunction("handleTabWithoutAcceptingCompletion"), extractFunction("performNormalTab"), extractFunction("insertNewlineWithoutCompletion"), extractFunction("acceptCompletionOrNextSnippetField"), @@ -87,11 +91,26 @@ function createHarness(options: { "codeMirrorNextSnippetField", "codeMirrorIndentMore", "settingsStore", + "normalizeShortcutSettings", + "shortcutToCodeMirrorKey", `${javascript}\nreturn { handleTab, insertNewlineWithoutCompletion, acceptCompletionOrNextSnippetField, clearPendingCompletionTab, consumeSqlCompletionAutoStartSuppression };`, ); - return factory(options.completionStatus, options.acceptCompletion ?? (() => false), options.closeCompletion ?? (() => false), options.insertNewlineKeepIndent ?? (() => false), options.nextSnippetField ?? (() => false), options.indentMore ?? (() => false), { - editorSettings: { sqlFormatter: { useTabs: false, tabWidth: 2 } }, - }) as TabHarness; + return factory( + options.completionStatus, + options.acceptCompletion ?? (() => false), + options.closeCompletion ?? (() => false), + options.insertNewlineKeepIndent ?? (() => false), + options.nextSnippetField ?? (() => false), + options.indentMore ?? (() => false), + { + editorSettings: { + sqlFormatter: { useTabs: false, tabWidth: 2 }, + shortcuts: { ...DEFAULT_SHORTCUT_SETTINGS, acceptCompletion: options.acceptCompletionShortcut ?? DEFAULT_SHORTCUT_SETTINGS.acceptCompletion }, + }, + }, + normalizeShortcutSettings, + shortcutToCodeMirrorKey, + ) as TabHarness; } function createView(text = "SELECT", position = text.length): MockView { @@ -237,6 +256,27 @@ describe("QueryEditor completion Tab keymap", () => { expect(view.dispatch).not.toHaveBeenCalled(); }); + it("does not accept an open completion popup on Tab when the configured accept-completion shortcut is Enter (dbx#6236)", () => { + const acceptCompletion = vi.fn(() => true); + const nextSnippetField = vi.fn(() => true); + const harness = createHarness({ completionStatus: () => "active", acceptCompletion, nextSnippetField, acceptCompletionShortcut: "Enter" }); + const view = createView(); + + expect(harness.handleTab(view)).toBe(true); + expect(acceptCompletion).not.toHaveBeenCalled(); + expect(nextSnippetField).not.toHaveBeenCalled(); + expect(view.state.replaceSelection).toHaveBeenCalledWith(" "); + }); + + it("still accepts an open completion popup on Tab when the configured shortcut is left at its Tab default", () => { + const acceptCompletion = vi.fn(() => true); + const harness = createHarness({ completionStatus: () => "active", acceptCompletion, acceptCompletionShortcut: "Tab" }); + const view = createView(); + + expect(harness.handleTab(view)).toBe(true); + expect(acceptCompletion).toHaveBeenCalledWith(view); + }); + it("falls back to normal Tab when pending completion has no candidate", async () => { vi.useFakeTimers(); let status: "active" | "pending" | null = "pending";