diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 546e7c04f7..d689837ac6 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -132,6 +132,7 @@ import { createShellLineCommentHighlight } from "@/lib/editor/codemirrorShellLin import { extendQueryEditorSelection, runQueryEditorAltExtendSelection } from "@/lib/editor/queryEditorExtendSelection"; import { createQueryEditorStringMouseSelection } from "@/lib/editor/queryEditorStringMouseSelection"; import { createQueryEditorCompletionShortcutBindings } from "@/lib/editor/queryEditorCompletionShortcut"; +import { createQueryEditorSelectionCaseShortcutBindings } from "@/lib/editor/queryEditorSelectionCaseShortcut"; import { acceptSelectedCompletionWithRetry, acceptSelectedOrFirstCompletion } from "@/lib/editor/queryEditorCompletionAcceptance"; import { createQueryEditorExecutionShortcutBindings, createQueryEditorPostCompositionKeyGuard } from "@/lib/editor/queryEditorExecutionShortcut"; import type { StatementExecutionMarker } from "@/lib/tabs/tabPresentation"; @@ -1974,8 +1975,8 @@ function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view") ...binding(shortcuts.redo, (view) => codeMirrorRedo?.(view) ?? false), ...binding(shortcuts.selectAll, (view) => codeMirrorSelectAll?.(view) ?? false), ...binding(shortcuts.extendSelection, extendQueryEditorSelectionForView), - ...binding(shortcuts.uppercaseSelection, () => convertSelectedSqlCase("upper")), - ...binding(shortcuts.lowercaseSelection, () => convertSelectedSqlCase("lower")), + ...createQueryEditorSelectionCaseShortcutBindings(shortcuts.uppercaseSelection, () => convertSelectedSqlCase("upper")), + ...createQueryEditorSelectionCaseShortcutBindings(shortcuts.lowercaseSelection, () => convertSelectedSqlCase("lower")), ...binding(shortcuts.toggleLineComment, (view) => codeMirrorToggleLineComment?.(view) ?? false), ...binding(shortcuts.toggleBlockComment, (view) => { if (!supportsQueryEditorBlockComments(props.databaseType)) return false; diff --git a/apps/desktop/src/lib/__tests__/editor/keyboardShortcuts.spec.ts b/apps/desktop/src/lib/__tests__/editor/keyboardShortcuts.spec.ts index eba0f5711a..55cfb5d019 100644 --- a/apps/desktop/src/lib/__tests__/editor/keyboardShortcuts.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/keyboardShortcuts.spec.ts @@ -41,6 +41,13 @@ describe("keyboard shortcut matching", () => { expect(eventToShortcut({ key: "+", ctrlKey: true, shiftKey: true }, "Win32")).toBe("Shift+Mod+Plus"); }); + it.each([ + ["¨", "KeyU", "Shift+Alt+U"], + ["Ò", "KeyL", "Shift+Alt+L"], + ])("records macOS Option-modified %s by physical letter", (key, code, expected) => { + expect(eventToShortcut({ key, code, altKey: true, shiftKey: true }, "MacIntel")).toBe(expected); + }); + it("keeps Control distinct from Command when recording macOS shortcuts", () => { const controlShortcut = eventToShortcut({ key: "b", ctrlKey: true }, "MacIntel"); diff --git a/apps/desktop/src/lib/__tests__/editor/queryEditorSelectionCaseShortcut.spec.ts b/apps/desktop/src/lib/__tests__/editor/queryEditorSelectionCaseShortcut.spec.ts new file mode 100644 index 0000000000..da0cefde70 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/editor/queryEditorSelectionCaseShortcut.spec.ts @@ -0,0 +1,68 @@ +// @vitest-environment happy-dom + +import { EditorState, EditorSelection } from "@codemirror/state"; +import { EditorView, keymap, runScopeHandlers } from "@codemirror/view"; +import { describe, expect, it, vi } from "vitest"; +import { createQueryEditorSelectionCaseShortcutBindings } from "@/lib/editor/queryEditorSelectionCaseShortcut"; + +function createView(shortcut: string, run: (view: EditorView) => boolean, platform: string): EditorView { + return new EditorView({ + parent: document.createElement("div"), + state: EditorState.create({ + doc: "select MixedCase", + selection: EditorSelection.range(0, 16), + extensions: [keymap.of(createQueryEditorSelectionCaseShortcutBindings(shortcut, run, platform))], + }), + }); +} + +describe("QueryEditor selection case shortcuts", () => { + it.each([ + ["Shift+Alt+U", "U"], + ["Shift+Alt+L", "L"], + ])("matches macOS %s by physical key when Option transforms event.key", (shortcut, code) => { + const run = vi.fn(() => true); + const view = createView(shortcut, run, "MacIntel"); + const event = new KeyboardEvent("keydown", { + key: code === "U" ? "¨" : "Ò", + code: `Key${code}`, + altKey: true, + shiftKey: true, + bubbles: true, + cancelable: true, + }); + + expect(runScopeHandlers(view, event, "editor")).toBe(true); + expect(run).toHaveBeenCalledOnce(); + view.destroy(); + }); + + it("leaves the shortcut unhandled when the conversion command declines", () => { + const run = vi.fn(() => false); + const view = createView("Shift+Alt+U", run, "MacIntel"); + const event = new KeyboardEvent("keydown", { + key: "¨", + code: "KeyU", + altKey: true, + shiftKey: true, + bubbles: true, + cancelable: true, + }); + + expect(runScopeHandlers(view, event, "editor")).toBe(false); + expect(event.defaultPrevented).toBe(false); + expect(run).toHaveBeenCalledOnce(); + view.destroy(); + }); + + it("keeps multi-stroke custom shortcuts on CodeMirror's sequence binding", () => { + const run = vi.fn(() => true); + expect(createQueryEditorSelectionCaseShortcutBindings("Mod+K Mod+U", run, "MacIntel")).toEqual([ + { + key: "Mod-k Mod-u", + preventDefault: true, + run, + }, + ]); + }); +}); diff --git a/apps/desktop/src/lib/editor/keyboardShortcuts.ts b/apps/desktop/src/lib/editor/keyboardShortcuts.ts index bc88feedd5..0031f6f64f 100644 --- a/apps/desktop/src/lib/editor/keyboardShortcuts.ts +++ b/apps/desktop/src/lib/editor/keyboardShortcuts.ts @@ -37,10 +37,17 @@ function shortcutKeyName(key: string): string | null { return key; } +function shortcutKeyNameFromEvent(event: ShortcutLikeEvent, platform: string): string | null { + if (event.altKey && isMacShortcutPlatform(platform) && /^Key[A-Z]$/.test(event.code ?? "")) { + return event.code!.slice(3); + } + return shortcutKeyName(event.key); +} + export function eventToShortcut(event: ShortcutLikeEvent, platform = globalThis.navigator?.platform || ""): string | null { if (event.isComposing) return null; - const key = shortcutKeyName(event.key); + const key = shortcutKeyNameFromEvent(event, platform); if (!key) return null; const hasModifier = !!event.metaKey || !!event.ctrlKey || !!event.altKey || !!event.shiftKey; diff --git a/apps/desktop/src/lib/editor/queryEditorSelectionCaseShortcut.ts b/apps/desktop/src/lib/editor/queryEditorSelectionCaseShortcut.ts new file mode 100644 index 0000000000..e129260a1f --- /dev/null +++ b/apps/desktop/src/lib/editor/queryEditorSelectionCaseShortcut.ts @@ -0,0 +1,23 @@ +import type { Command, KeyBinding } from "@codemirror/view"; +import { matchesShortcut } from "@/lib/editor/keyboardShortcuts"; +import { parseShortcutStrokes } from "@/lib/editor/shortcutDisplay"; +import { shortcutToCodeMirrorKey } from "@/lib/editor/shortcutRegistry"; + +export function createQueryEditorSelectionCaseShortcutBindings(shortcut: string, run: Command, platform = globalThis.navigator?.platform || ""): KeyBinding[] { + if (!shortcut) return []; + + // CodeMirror matches event.key, but macOS Option+letter produces a composed + // character. Use the shared physical-key fallback for single-stroke bindings. + if (parseShortcutStrokes(shortcut).length === 1) { + return [ + { + any(view, event) { + if (!matchesShortcut(event, shortcut, platform)) return false; + return run(view); + }, + }, + ]; + } + + return [{ key: shortcutToCodeMirrorKey(shortcut), preventDefault: true, run }]; +}