From f0cc5aefcfcd1c0a2f2eab5c1267d22f1b537b66 Mon Sep 17 00:00:00 2001 From: Melody Ruch <396921921@qq.com> Date: Tue, 18 Aug 2026 15:24:16 +0800 Subject: [PATCH] fix(editor): let global shortcuts override CodeMirror bindings --- .../src/components/editor/QueryEditor.vue | 22 +++++- .../editor/codemirrorDefaultKeymap.spec.ts | 75 +++++++++++++++++++ .../editor/queryEditorBlockComment.spec.ts | 3 +- .../src/lib/editor/codemirrorDefaultKeymap.ts | 12 +++ 4 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/editor/codemirrorDefaultKeymap.spec.ts create mode 100644 apps/desktop/src/lib/editor/codemirrorDefaultKeymap.ts diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index c0b90022fe..cc994111b4 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -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 | null = null; let setSqlDiagnosticsEffect: import("@codemirror/state").StateEffectType | 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], () => { diff --git a/apps/desktop/src/lib/__tests__/editor/codemirrorDefaultKeymap.spec.ts b/apps/desktop/src/lib/__tests__/editor/codemirrorDefaultKeymap.spec.ts new file mode 100644 index 0000000000..39950712fe --- /dev/null +++ b/apps/desktop/src/lib/__tests__/editor/codemirrorDefaultKeymap.spec.ts @@ -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(); + }); + } +}); diff --git a/apps/desktop/src/lib/__tests__/editor/queryEditorBlockComment.spec.ts b/apps/desktop/src/lib/__tests__/editor/queryEditorBlockComment.spec.ts index 7ff0b8aa27..347bdb4ee7 100644 --- a/apps/desktop/src/lib/__tests__/editor/queryEditorBlockComment.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/queryEditorBlockComment.spec.ts @@ -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;"); }); diff --git a/apps/desktop/src/lib/editor/codemirrorDefaultKeymap.ts b/apps/desktop/src/lib/editor/codemirrorDefaultKeymap.ts new file mode 100644 index 0000000000..d18993eb6f --- /dev/null +++ b/apps/desktop/src/lib/editor/codemirrorDefaultKeymap.ts @@ -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): 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)); +}