diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index 27210c9329..4f2df63d5e 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -909,6 +909,7 @@ const formatterEditorShortcutIds: ShortcutActionId[] = [ "replace", "saveSql", "acceptCompletion", + "triggerCompletion", "indentMore", "indentLess", "insertLineBelow", diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 1bb091aaa4..fed5151954 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -121,6 +121,7 @@ import { startsQueryEditorRectangularSelection, usesQueryEditorObjectNavigationM import { LARGE_PASTE_HISTORY_USER_EVENT, normalizeQueryEditorPasteText, recoverableNativePasteSuffix, shouldRecoverLargeTauriPaste } from "@/lib/editor/queryEditorLargePaste"; import { computePasteCaretResyncTarget } from "@/lib/editor/queryEditorPasteCaretResync"; import { extendQueryEditorSelection, runQueryEditorAltExtendSelection } from "@/lib/editor/queryEditorExtendSelection"; +import { createQueryEditorCompletionShortcutBindings } from "@/lib/editor/queryEditorCompletionShortcut"; 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"; @@ -1930,6 +1931,7 @@ function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view") return true; }), ...binding(shortcuts.sqlIntentionActions, handleSqlIntentionActions), + ...createQueryEditorCompletionShortcutBindings(shortcuts.triggerCompletion, triggerSqlCompletion), ...createQueryEditorSearchKeymap({ openSearch, openReplace, @@ -3643,6 +3645,15 @@ function isEditorComposing(currentView: EditorViewType): boolean { return imeCompositionActive || currentView.compositionStarted || currentView.composing; } +// Manual-trigger shortcut (default Alt+/). Opens the completion popup on the +// explicit path so auto-trigger mode gating is bypassed. Unlike +// scheduleSqlCompletionStart, it must NOT mark the activation as typed, or the +// session would be misclassified as typing and gated for 500ms. +function triggerSqlCompletion(currentView: EditorViewType): boolean { + if (!codeMirrorStartCompletion || isEditorComposing(currentView)) return false; + return codeMirrorStartCompletion(currentView); +} + function scheduleSqlCompletionStart(currentView: EditorViewType, delayMs = 0) { window.setTimeout(() => { if (!codeMirrorStartCompletion || isEditorComposing(currentView)) return; diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 8744d516a3..f0c2db7229 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -6117,6 +6117,7 @@ export default { shortcutReplace: "Replace", shortcutSaveSql: "Save SQL", shortcutAcceptCompletion: "Accept completion", + shortcutTriggerCompletion: "Trigger completion", shortcutFormatSql: "Format SQL", shortcutExpandSelectStar: "Expand * to columns", shortcutToggleLineComment: "Toggle line comment", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index ce31f77235..158d05d841 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -5809,6 +5809,7 @@ export default withEnglishFallback({ shortcutReplace: "Reemplazar", shortcutSaveSql: "Guardar SQL", shortcutAcceptCompletion: "Aceptar completado", + shortcutTriggerCompletion: "Activar completado", shortcutFormatSql: "Formatear SQL", shortcutExpandSelectStar: "Expandir * a columnas", shortcutToggleLineComment: "Alternar comentario de línea", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 70bda8628d..bcc1e35ff7 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -5809,6 +5809,7 @@ export default withEnglishFallback({ shortcutReplace: "Sostituisci", shortcutSaveSql: "Salva SQL", shortcutAcceptCompletion: "Accetta completamento", + shortcutTriggerCompletion: "Attiva completamento", shortcutFormatSql: "Formatta SQL", shortcutExpandSelectStar: "Espandi * in colonne", shortcutToggleLineComment: "Attiva/disattiva commento di riga", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 22860cd9a9..e39581346c 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -5824,6 +5824,7 @@ export default withEnglishFallback({ shortcutReplace: "置換", shortcutSaveSql: "SQLを保存", shortcutAcceptCompletion: "補完を受け入れる", + shortcutTriggerCompletion: "補完をトリガー", shortcutFormatSql: "SQLをフォーマット", shortcutExpandSelectStar: "* を列に展開", shortcutToggleLineComment: "行コメントを切り替え", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index a0fd96221a..93cb3efac3 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -5566,6 +5566,7 @@ export default withEnglishFallback({ shortcutReplace: "바꾸기", shortcutSaveSql: "SQL 저장", shortcutAcceptCompletion: "완성 수락", + shortcutTriggerCompletion: "완성 트리거", shortcutFormatSql: "SQL 서식", shortcutExpandSelectStar: "*을(를) 열로 확장", shortcutToggleLineComment: "줄 주석 전환", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index af557dc640..955020246f 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -5811,6 +5811,7 @@ export default withEnglishFallback({ shortcutReplace: "Substituir", shortcutSaveSql: "Salvar SQL", shortcutAcceptCompletion: "Aceitar autocompletar", + shortcutTriggerCompletion: "Acionar autocompletar", shortcutFormatSql: "Formatar SQL", shortcutExpandSelectStar: "Expandir * em colunas", shortcutToggleLineComment: "Alternar comentário de linha", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 8e30ba7977..17b9aec36e 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -6102,6 +6102,7 @@ export default withEnglishFallback({ shortcutReplace: "替换", shortcutSaveSql: "保存 SQL", shortcutAcceptCompletion: "接受补全", + shortcutTriggerCompletion: "触发补全", shortcutFormatSql: "格式化 SQL", shortcutExpandSelectStar: "将 * 展开为字段", shortcutToggleLineComment: "切换行注释", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 01e67dae96..10e723cb56 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -5134,6 +5134,7 @@ export default withEnglishFallback({ shortcutReplace: "取代", shortcutSaveSql: "儲存 SQL", shortcutAcceptCompletion: "接受補全", + shortcutTriggerCompletion: "觸發補全", shortcutFormatSql: "格式化 SQL", shortcutExpandSelectStar: "將 * 展開為欄位", shortcutToggleLineComment: "切換行註解", diff --git a/apps/desktop/src/lib/__tests__/editor/queryEditorCompletionKeymap.spec.ts b/apps/desktop/src/lib/__tests__/editor/queryEditorCompletionKeymap.spec.ts index 0236132a43..bc99cd5b90 100644 --- a/apps/desktop/src/lib/__tests__/editor/queryEditorCompletionKeymap.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/queryEditorCompletionKeymap.spec.ts @@ -294,4 +294,25 @@ describe("QueryEditor completion Tab keymap", () => { expect(view.state.replaceSelection).toHaveBeenCalledWith(" "); expect(view.dispatch).toHaveBeenCalledOnce(); }); + + it("triggers completion on Alt+/ through the manual shortcut and skips while composing", () => { + const source = [extractDeclaration(/const COMPLETION_DEBOUNCE_DELAY_MS = \d+;/, "completion debounce delay"), "let imeCompositionActive = false;", extractFunction("isEditorComposing"), extractFunction("triggerSqlCompletion")].join("\n"); + const javascript = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.None, target: ts.ScriptTarget.ES2022 }, + }).outputText; + const factory = new Function("codeMirrorStartCompletion", `${javascript}\nreturn { triggerSqlCompletion, isEditorComposing };`); + const startCompletion = vi.fn(() => true); + const { triggerSqlCompletion } = factory(startCompletion) as { + triggerSqlCompletion: (view: MockView) => boolean; + }; + const view = createView(); + + expect(triggerSqlCompletion(view)).toBe(true); + expect(startCompletion).toHaveBeenCalledWith(view); + + startCompletion.mockClear(); + (view as MockView & { composing: boolean }).composing = true; + expect(triggerSqlCompletion(view)).toBe(false); + expect(startCompletion).not.toHaveBeenCalled(); + }); }); diff --git a/apps/desktop/src/lib/__tests__/editor/queryEditorCompletionShortcut.spec.ts b/apps/desktop/src/lib/__tests__/editor/queryEditorCompletionShortcut.spec.ts new file mode 100644 index 0000000000..18944f3f43 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/editor/queryEditorCompletionShortcut.spec.ts @@ -0,0 +1,73 @@ +// @vitest-environment happy-dom + +import { EditorState } from "@codemirror/state"; +import { EditorView, keymap, runScopeHandlers } from "@codemirror/view"; +import { describe, expect, it, vi } from "vitest"; +import { createQueryEditorCompletionShortcutBindings } from "@/lib/editor/queryEditorCompletionShortcut"; + +function createView(shortcut: string, run: (view: EditorView) => boolean, platform: string): EditorView { + return new EditorView({ + parent: document.createElement("div"), + state: EditorState.create({ + extensions: [keymap.of(createQueryEditorCompletionShortcutBindings(shortcut, run, platform))], + }), + }); +} + +describe("QueryEditor manual completion shortcut", () => { + it("dispatches macOS Option+/ by the physical Slash key", () => { + const run = vi.fn(() => true); + const view = createView("Alt+/", run, "MacIntel"); + const event = new KeyboardEvent("keydown", { + key: "÷", + code: "Slash", + altKey: true, + bubbles: true, + cancelable: true, + }); + + expect(runScopeHandlers(view, event, "editor")).toBe(true); + expect(run).toHaveBeenCalledOnce(); + expect(run).toHaveBeenCalledWith(view); + view.destroy(); + }); + + it("leaves macOS Option+/ unhandled during IME composition", () => { + const run = vi.fn(() => true); + const view = createView("Alt+/", run, "MacIntel"); + const event = new KeyboardEvent("keydown", { + key: "/", + code: "Slash", + altKey: true, + isComposing: true, + bubbles: true, + cancelable: true, + }); + + expect(runScopeHandlers(view, event, "editor")).toBe(false); + expect(event.defaultPrevented).toBe(false); + expect(run).not.toHaveBeenCalled(); + view.destroy(); + }); + + it("does not force-handle a configured shortcut when completion does not start", () => { + const run = vi.fn(() => false); + const view = createView("Alt+K", run, "MacIntel"); + const event = new KeyboardEvent("keydown", { + key: "k", + code: "KeyK", + altKey: true, + bubbles: true, + cancelable: true, + }); + + expect(runScopeHandlers(view, event, "editor")).toBe(false); + expect(event.defaultPrevented).toBe(false); + expect(run).toHaveBeenCalledOnce(); + view.destroy(); + }); + + it("keeps an empty configured shortcut disabled", () => { + expect(createQueryEditorCompletionShortcutBindings("", () => true, "MacIntel")).toEqual([]); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts b/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts index 8b537ff41b..4f8686e2a5 100644 --- a/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts @@ -154,6 +154,17 @@ describe("shortcutRegistry editor actions", () => { expect(DEFAULT_SHORTCUT_SETTINGS.extendSelection).toBe("Alt+W"); }); + it("registers an IDEA/DataGrip-style Alt+/ shortcut for manually triggering completion", () => { + const definition = SHORTCUT_DEFINITIONS.find((item) => item.id === "triggerCompletion"); + + expect(definition).toMatchObject({ scope: "editor", defaultShortcut: "Alt+/" }); + expect(DEFAULT_SHORTCUT_SETTINGS.triggerCompletion).toBe("Alt+/"); + expect(formatShortcut(DEFAULT_SHORTCUT_SETTINGS.triggerCompletion, "Win32")).toBe("Alt+/"); + expect(formatShortcut(DEFAULT_SHORTCUT_SETTINGS.triggerCompletion, "MacIntel")).toBe("Alt+/"); + expect(shortcutToCodeMirrorKey(DEFAULT_SHORTCUT_SETTINGS.triggerCompletion)).toBe("Alt-/"); + expect(findShortcutConflict("triggerCompletion", DEFAULT_SHORTCUT_SETTINGS.triggerCompletion, DEFAULT_SHORTCUT_SETTINGS)).toBeNull(); + }); + it("detects conflicts between formatter editor shortcuts and other editor shortcuts", () => { const shortcuts = normalizeShortcutSettings({ duplicateLine: "Mod+F" }); diff --git a/apps/desktop/src/lib/editor/queryEditorCompletionShortcut.ts b/apps/desktop/src/lib/editor/queryEditorCompletionShortcut.ts new file mode 100644 index 0000000000..fecbb0fce7 --- /dev/null +++ b/apps/desktop/src/lib/editor/queryEditorCompletionShortcut.ts @@ -0,0 +1,26 @@ +import type { Command, KeyBinding } from "@codemirror/view"; +import { matchesShortcut, type ShortcutLikeEvent } from "@/lib/editor/keyboardShortcuts"; +import { isMacShortcutPlatform } from "@/lib/editor/shortcutDisplay"; +import { shortcutToCodeMirrorKey } from "@/lib/editor/shortcutRegistry"; + +function matchesMacOptionSlash(event: ShortcutLikeEvent, shortcut: string, platform: string): boolean { + if (!isMacShortcutPlatform(platform) || shortcut !== "Alt+/" || event.isComposing) return false; + if (!event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return false; + return event.code === "Slash"; +} + +export function createQueryEditorCompletionShortcutBindings(shortcut: string, run: Command, platform = globalThis.navigator?.platform || ""): KeyBinding[] { + if (!shortcut) return []; + if (!isMacShortcutPlatform(platform) || shortcut !== "Alt+/") { + return [{ key: shortcutToCodeMirrorKey(shortcut), run }]; + } + return [ + { + any(view, event) { + if (event.isComposing) return false; + if (!matchesShortcut(event, shortcut, platform) && !matchesMacOptionSlash(event, shortcut, platform)) return false; + return run(view); + }, + }, + ]; +} diff --git a/apps/desktop/src/lib/editor/shortcutRegistry.ts b/apps/desktop/src/lib/editor/shortcutRegistry.ts index 4cceeb07a6..efabe606c1 100644 --- a/apps/desktop/src/lib/editor/shortcutRegistry.ts +++ b/apps/desktop/src/lib/editor/shortcutRegistry.ts @@ -9,6 +9,7 @@ export type ShortcutActionId = | "toggleBlockComment" | "saveSql" | "acceptCompletion" + | "triggerCompletion" | "indentMore" | "indentLess" | "insertLineBelow" @@ -138,6 +139,12 @@ export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [ scope: "editor", defaultShortcut: "Tab", }, + { + id: "triggerCompletion", + labelKey: "settings.shortcutTriggerCompletion", + scope: "editor", + defaultShortcut: "Alt+/", + }, { id: "indentMore", labelKey: "settings.shortcutIndentMore",