feat(editor): add configurable completion shortcut
This commit is contained in:
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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -5824,6 +5824,7 @@ export default withEnglishFallback({
|
||||
shortcutReplace: "置換",
|
||||
shortcutSaveSql: "SQLを保存",
|
||||
shortcutAcceptCompletion: "補完を受け入れる",
|
||||
shortcutTriggerCompletion: "補完をトリガー",
|
||||
shortcutFormatSql: "SQLをフォーマット",
|
||||
shortcutExpandSelectStar: "* を列に展開",
|
||||
shortcutToggleLineComment: "行コメントを切り替え",
|
||||
|
||||
@@ -5566,6 +5566,7 @@ export default withEnglishFallback({
|
||||
shortcutReplace: "바꾸기",
|
||||
shortcutSaveSql: "SQL 저장",
|
||||
shortcutAcceptCompletion: "완성 수락",
|
||||
shortcutTriggerCompletion: "완성 트리거",
|
||||
shortcutFormatSql: "SQL 서식",
|
||||
shortcutExpandSelectStar: "*을(를) 열로 확장",
|
||||
shortcutToggleLineComment: "줄 주석 전환",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -6102,6 +6102,7 @@ export default withEnglishFallback({
|
||||
shortcutReplace: "替换",
|
||||
shortcutSaveSql: "保存 SQL",
|
||||
shortcutAcceptCompletion: "接受补全",
|
||||
shortcutTriggerCompletion: "触发补全",
|
||||
shortcutFormatSql: "格式化 SQL",
|
||||
shortcutExpandSelectStar: "将 * 展开为字段",
|
||||
shortcutToggleLineComment: "切换行注释",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user