feat(editor): add configurable completion shortcut

This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-08-18 10:52:37 +08:00
1 parent e2b8ce3408
commit e6fa850a31
15 files changed
+158

No files matched your search

@@ -909,6 +909,7 @@ const formatterEditorShortcutIds: ShortcutActionId[] = [
"replace",
"saveSql",
"acceptCompletion",
"triggerCompletion",
"indentMore",
"indentLess",
"insertLineBelow",
@@ -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;
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -5824,6 +5824,7 @@ export default withEnglishFallback({
shortcutReplace: "置換",
shortcutSaveSql: "SQLを保存",
shortcutAcceptCompletion: "補完を受け入れる",
shortcutTriggerCompletion: "補完をトリガー",
shortcutFormatSql: "SQLをフォーマット",
shortcutExpandSelectStar: "* を列に展開",
shortcutToggleLineComment: "行コメントを切り替え",
+1
View File
@@ -5566,6 +5566,7 @@ export default withEnglishFallback({
shortcutReplace: "바꾸기",
shortcutSaveSql: "SQL 저장",
shortcutAcceptCompletion: "완성 수락",
shortcutTriggerCompletion: "완성 트리거",
shortcutFormatSql: "SQL 서식",
shortcutExpandSelectStar: "*을(를) 열로 확장",
shortcutToggleLineComment: "줄 주석 전환",
+1
View File
@@ -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",
+1
View File
@@ -6102,6 +6102,7 @@ export default withEnglishFallback({
shortcutReplace: "替换",
shortcutSaveSql: "保存 SQL",
shortcutAcceptCompletion: "接受补全",
shortcutTriggerCompletion: "触发补全",
shortcutFormatSql: "格式化 SQL",
shortcutExpandSelectStar: "将 * 展开为字段",
shortcutToggleLineComment: "切换行注释",
+1
View File
@@ -5134,6 +5134,7 @@ export default withEnglishFallback({
shortcutReplace: "取代",
shortcutSaveSql: "儲存 SQL",
shortcutAcceptCompletion: "接受補全",
shortcutTriggerCompletion: "觸發補全",
shortcutFormatSql: "格式化 SQL",
shortcutExpandSelectStar: "將 * 展開為欄位",
shortcutToggleLineComment: "切換行註解",
@@ -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();
});
});
@@ -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([]);
});
});
@@ -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" });
@@ -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);
},
},
];
}
@@ -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",