diff --git a/apps/desktop/src/lib/shortcuts/hotkeys.test.ts b/apps/desktop/src/lib/shortcuts/hotkeys.test.ts new file mode 100644 index 00000000000..504e50c0d45 --- /dev/null +++ b/apps/desktop/src/lib/shortcuts/hotkeys.test.ts @@ -0,0 +1,40 @@ +import { isSelectAllChord } from "$lib/shortcuts/hotkeys"; +import { describe, expect, test } from "vitest"; + +function keydown(key: string, modifiers: { metaKey?: boolean; ctrlKey?: boolean } = {}) { + return new KeyboardEvent("keydown", { key, ...modifiers }); +} + +/** The values `Backend["platformName"]` takes where ⌘ is not the select-all modifier. */ +const NON_MAC_PLATFORMS = ["windows", "linux", "web"] as const; + +describe("isSelectAllChord", () => { + test("macOS selects all with cmd", () => { + expect(isSelectAllChord(keydown("a", { metaKey: true }), "macos")).toBe(true); + }); + + // Ctrl+A is the emacs "move to the start of the line" binding that macOS text fields have, + // so claiming it for select-all takes an editing key away instead of adding a shortcut. + test("macOS leaves ctrl alone", () => { + expect(isSelectAllChord(keydown("a", { ctrlKey: true }), "macos")).toBe(false); + }); + + test.each(NON_MAC_PLATFORMS)("%s selects all with ctrl", (platform) => { + expect(isSelectAllChord(keydown("a", { ctrlKey: true }), platform)).toBe(true); + }); + + // Cmd is not a select-all modifier off macOS, where that key is the Super/Windows key. + test.each(NON_MAC_PLATFORMS)("%s ignores meta", (platform) => { + expect(isSelectAllChord(keydown("a", { metaKey: true }), platform)).toBe(false); + }); + + test("a bare 'a' types rather than selects", () => { + expect(isSelectAllChord(keydown("a"), "macos")).toBe(false); + expect(isSelectAllChord(keydown("a"), "windows")).toBe(false); + }); + + test("the modifier only counts together with 'a'", () => { + expect(isSelectAllChord(keydown("b", { metaKey: true }), "macos")).toBe(false); + expect(isSelectAllChord(keydown("b", { ctrlKey: true }), "windows")).toBe(false); + }); +}); diff --git a/apps/desktop/src/lib/shortcuts/hotkeys.ts b/apps/desktop/src/lib/shortcuts/hotkeys.ts index 84742c4f85e..89037a67da8 100644 --- a/apps/desktop/src/lib/shortcuts/hotkeys.ts +++ b/apps/desktop/src/lib/shortcuts/hotkeys.ts @@ -4,6 +4,22 @@ interface KeybindDefinitions { [combo: string]: (event: KeyboardEvent) => void; } +/** + * Whether `event` is `platform`'s "select all" chord. + * + * macOS selects all with ⌘ and leaves Ctrl+A as "move to the start of the line", the emacs + * binding its text fields carry everywhere else, so accepting either modifier there takes an + * editing key away rather than adding a shortcut. Everywhere else Ctrl is the one that selects. + * + * `platform` is a `Backend["platformName"]`, which the desktop app gets from the OS rather + * than from the user agent. + */ +export function isSelectAllChord(event: KeyboardEvent, platform: string): boolean { + if (event.key !== "a") return false; + + return platform === "macos" ? event.metaKey : event.ctrlKey; +} + export const shortcuts = { global: { open_repository: { diff --git a/apps/desktop/src/routes/+layout.svelte b/apps/desktop/src/routes/+layout.svelte index 974c9796f50..efcff8d326c 100644 --- a/apps/desktop/src/routes/+layout.svelte +++ b/apps/desktop/src/routes/+layout.svelte @@ -22,7 +22,7 @@ import { fModeEnabled } from "$lib/config/uiFeatureFlags"; import { PROJECTS_SERVICE } from "$lib/project/projectsService"; import { TERMINAL_SERVICE } from "$lib/settings/terminalService"; - import { createKeybind } from "$lib/shortcuts/hotkeys"; + import { createKeybind, isSelectAllChord } from "$lib/shortcuts/hotkeys"; import { SHORTCUT_SERVICE } from "$lib/shortcuts/shortcutService"; import { CLIENT_STATE } from "$lib/state/clientState.svelte"; import { initUserSettings, UI_STATE } from "$lib/state/uiState.svelte"; @@ -114,14 +114,12 @@ // ============================================================================= function handleKeyDown(e: KeyboardEvent) { - // Explicitly detect cmd/ctrl + A since Tauri gets in the way of default behavior. + // Explicitly detect the select-all chord since Tauri gets in the way of default behavior. // To get default behavior you can add a "Select All" predefined menu item to the // Edit menu, but that prevents the event from reaching the webview. if ( (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) && - (e.metaKey || e.ctrlKey) && - e.key === "a" && - e.target + isSelectAllChord(e, backend.platformName) ) { e.target.select(); e.preventDefault();