fix(editor): let global shortcuts override CodeMirror bindings

This commit is contained in:
Melody Ruch authored and GitHub committed 2026-08-18 15:24:16 +08:00
1 parent 9051ad35b8
commit f0cc5aefcf
4 files changed
+110 -2

No files matched your search

@@ -106,6 +106,7 @@ import { EDITOR_FONT_FAMILY_CSS_VAR, EDITOR_FONT_SIZE_CSS_VAR, editorDiagnosticC
import { createStatementGutterMarkerDom, shouldShowStatementGutter } from "@/lib/editor/codemirrorStatementGutter";
import { createQueryEditorSearchKeymap } from "@/lib/editor/queryEditorSearchKeymap";
import { searchKeymapWithoutModD } from "@/lib/editor/codemirrorSearchKeymap";
import { defaultKeymapForGlobalShortcuts } from "@/lib/editor/codemirrorDefaultKeymap";
import { appendSqlCompletionSpace } from "@/lib/editor/sqlCompletionInsertion";
import { compareSqlCompletions, completionLabelPresentation } from "@/lib/editor/sqlCompletionPresentation";
import { clampEditorFontSize, createEditorZoomCommitScheduler, fontSizeFromGestureScale, fontSizeFromWheelDelta } from "@/lib/editor/editorZoom";
@@ -521,6 +522,7 @@ let codeMirrorCloseBracketsKeymap: readonly import("@codemirror/view").KeyBindin
let readOnlyComp: import("@codemirror/state").Compartment | null = null;
let runGutterComp: import("@codemirror/state").Compartment | null = null;
let runKeymapComp: import("@codemirror/state").Compartment | null = null;
let defaultKeymapComp: import("@codemirror/state").Compartment | null = null;
let completionComp: import("@codemirror/state").Compartment | null = null;
let diagnosticComp: import("@codemirror/state").Compartment | null = null;
let codeMirrorVim: typeof import("@replit/codemirror-vim").vim | null = null;
@@ -553,6 +555,7 @@ let codeMirrorSelectAll: typeof import("@codemirror/commands").selectAll | null
let codeMirrorInsertNewlineKeepIndent: typeof import("@codemirror/commands").insertNewlineKeepIndent | null = null;
let codeMirrorToggleLineComment: typeof import("@codemirror/commands").toggleLineComment | null = null;
let codeMirrorToggleBlockComment: typeof import("@codemirror/commands").toggleBlockComment | null = null;
let codeMirrorDefaultKeymap: readonly import("@codemirror/view").KeyBinding[] | null = null;
let codeMirrorToggleFold: typeof import("@codemirror/language").toggleFold | null = null;
let pendingCompletionTabTimer: ReturnType<typeof setTimeout> | null = null;
let setSqlDiagnosticsEffect: import("@codemirror/state").StateEffectType<SqlSemanticDiagnostic[]> | null = null;
@@ -4420,6 +4423,11 @@ function refreshCompletionCache() {
cachedForeignKeysByTable.clear();
}
function defaultKeymapExtension() {
if (!editorViewModule || !codeMirrorDefaultKeymap || !codeMirrorToggleBlockComment) return [];
return editorViewModule.keymap.of(defaultKeymapForGlobalShortcuts(codeMirrorDefaultKeymap, settingsStore.editorSettings.shortcuts).filter((item) => item.run !== codeMirrorToggleBlockComment));
}
onMounted(async () => {
if (!editorRef.value) return;
@@ -4488,6 +4496,7 @@ onMounted(async () => {
readOnlyComp = new Compartment();
runGutterComp = new Compartment();
runKeymapComp = new Compartment();
defaultKeymapComp = new Compartment();
completionComp = new Compartment();
diagnosticComp = new Compartment();
previewRangeComp = new Compartment();
@@ -4512,6 +4521,7 @@ onMounted(async () => {
codeMirrorInsertNewlineKeepIndent = insertNewlineKeepIndent;
codeMirrorToggleLineComment = toggleLineComment;
codeMirrorToggleBlockComment = toggleBlockComment;
codeMirrorDefaultKeymap = defaultKeymap;
codeMirrorToggleFold = toggleFold;
codeMirrorIndentUnit = indentUnit;
window.addEventListener("keyup", clearTableNavigationHoverOnModifierRelease);
@@ -4908,7 +4918,8 @@ onMounted(async () => {
activeLineHighlighter,
// Vim must be mounted before DBX/default keymaps so normal-mode keys are handled first.
vimModeComp.of(vimModeExtension(initialSettings.vimModeEnabled)),
keymap.of([...defaultKeymap.filter((item) => item.run !== toggleBlockComment), ...searchKeymapWithoutModD(searchKeymap), ...historyKeymap, ...foldKeymap, ...completionKeymap]),
defaultKeymapComp.of(defaultKeymapExtension()),
keymap.of([...searchKeymapWithoutModD(searchKeymap), ...historyKeymap, ...foldKeymap, ...completionKeymap]),
sqlLanguageComp.of(buildSqlLanguageExtension()),
sqlSemanticHighlightComp.of(buildSqlSemanticHighlightExtension()),
tooltips({ parent: tooltipParent }),
@@ -5539,6 +5550,15 @@ watch(
{ deep: true },
);
watch(
() => settingsStore.editorSettings.shortcuts,
() => {
if (!view.value || !defaultKeymapComp) return;
view.value.dispatch({ effects: defaultKeymapComp.reconfigure(defaultKeymapExtension()) });
},
{ deep: true },
);
watch(
() => [settingsStore.editorSettings.sqlFormatter.tabWidth, settingsStore.editorSettings.sqlFormatter.useTabs],
() => {
@@ -0,0 +1,75 @@
// @vitest-environment happy-dom
import { defaultKeymap } from "@codemirror/commands";
import { EditorSelection, EditorState } from "@codemirror/state";
import { EditorView, keymap, runScopeHandlers, type KeyBinding } from "@codemirror/view";
import { describe, expect, it } from "vitest";
import { defaultKeymapForGlobalShortcuts } from "@/lib/editor/codemirrorDefaultKeymap";
import { matchesShortcut } from "@/lib/editor/keyboardShortcuts";
const platforms = [
{ name: "macOS", platform: "MacIntel", modifier: "Meta", event: { metaKey: true } },
{ name: "Windows", platform: "Win32", modifier: "Ctrl", event: { ctrlKey: true } },
{ name: "Linux", platform: "Linux x86_64", modifier: "Ctrl", event: { ctrlKey: true } },
] as const;
function platformIndentBindings(bindings: readonly KeyBinding[], modifier: "Meta" | "Ctrl"): KeyBinding[] {
return bindings.filter((binding) => binding.key === "Mod-[" || binding.key === "Mod-]").map((binding) => ({ ...binding, key: binding.key?.replace("Mod", modifier) }));
}
function createView(doc: string, bindings: readonly KeyBinding[], selection?: EditorSelection): EditorView {
return new EditorView({
parent: document.createElement("div"),
state: EditorState.create({
doc,
selection,
extensions: [keymap.of(bindings)],
}),
});
}
describe("defaultKeymapForGlobalShortcuts", () => {
it("removes only the bracket binding occupied by a global shortcut", () => {
const filtered = defaultKeymapForGlobalShortcuts(defaultKeymap, { toggleSidebar: "Mod+[" });
expect(filtered.find((binding) => binding.key === "Mod-[")).toBeUndefined();
expect(filtered.find((binding) => binding.key === "Mod-]")).toBeDefined();
expect(filtered.find((binding) => binding.key === "Mod-a")).toBeDefined();
});
for (const { name, platform, modifier, event } of platforms) {
for (const key of ["[", "]"] as const) {
it(`${name} lets a conflicting global Mod+${key} action run while the editor is focused`, () => {
const shortcut = `Mod+${key}`;
const bindings = platformIndentBindings(defaultKeymapForGlobalShortcuts(defaultKeymap, { toggleSidebar: shortcut }), modifier);
const view = createView(" select 1", bindings, EditorSelection.cursor(2));
const keyboardEvent = new KeyboardEvent("keydown", { key, ...event });
let globalActionRuns = 0;
if (!runScopeHandlers(view, keyboardEvent, "editor") && matchesShortcut(keyboardEvent, shortcut, platform)) globalActionRuns++;
expect(globalActionRuns).toBe(1);
expect(view.state.doc.toString()).toBe(" select 1");
view.destroy();
});
}
it(`${name} preserves single-line Mod+[ outdent without a global conflict`, () => {
const bindings = platformIndentBindings(defaultKeymapForGlobalShortcuts(defaultKeymap, { toggleSidebar: "Mod+B" }), modifier);
const view = createView(" select 1", bindings, EditorSelection.cursor(2));
expect(runScopeHandlers(view, new KeyboardEvent("keydown", { key: "[", ...event }), "editor")).toBe(true);
expect(view.state.doc.toString()).toBe("select 1");
view.destroy();
});
it(`${name} preserves multi-line Mod+] indent without a global conflict`, () => {
const doc = "select 1\nselect 2";
const bindings = platformIndentBindings(defaultKeymapForGlobalShortcuts(defaultKeymap, { toggleSidebar: "Mod+B" }), modifier);
const view = createView(doc, bindings, EditorSelection.range(0, doc.length));
expect(runScopeHandlers(view, new KeyboardEvent("keydown", { key: "]", ...event }), "editor")).toBe(true);
expect(view.state.doc.toString()).toBe(" select 1\n select 2");
view.destroy();
});
}
});
@@ -29,7 +29,8 @@ describe("QueryEditor block comment", () => {
expect(queryEditorSource).toContain("shortcut: shortcuts.toggleBlockComment");
expect(queryEditorSource).toContain("...binding(shortcuts.toggleBlockComment, (view) => {");
expect(queryEditorSource).toContain("!supportsQueryEditorBlockComments(props.databaseType)");
expect(queryEditorSource).toContain("defaultKeymap.filter((item) => item.run !== toggleBlockComment)");
expect(queryEditorSource).toContain("defaultKeymapForGlobalShortcuts(codeMirrorDefaultKeymap, settingsStore.editorSettings.shortcuts).filter((item) => item.run !== codeMirrorToggleBlockComment)");
expect(queryEditorSource).toContain("defaultKeymapComp.reconfigure(defaultKeymapExtension())");
expect(queryEditorSource).toContain("codeMirrorToggleBlockComment = toggleBlockComment;");
});
@@ -0,0 +1,12 @@
import type { KeyBinding } from "@codemirror/view";
import { normalizeShortcutSettings, SHORTCUT_DEFINITIONS, shortcutToCodeMirrorKey, type ShortcutSettings } from "@/lib/editor/shortcutRegistry";
const indentBracketKeys = new Set(["Mod-[", "Mod-]"]);
const globalShortcutDefinitions = SHORTCUT_DEFINITIONS.filter((definition) => definition.scope === "global");
export function defaultKeymapForGlobalShortcuts(defaultKeymap: readonly KeyBinding[], shortcuts?: Partial<ShortcutSettings>): KeyBinding[] {
const normalizedShortcuts = normalizeShortcutSettings(shortcuts);
const occupiedGlobalKeys = new Set(globalShortcutDefinitions.map((definition) => shortcutToCodeMirrorKey(normalizedShortcuts[definition.id])).filter(Boolean));
return defaultKeymap.filter((binding) => !binding.key || !indentBracketKeys.has(binding.key) || !occupiedGlobalKeys.has(binding.key));
}