From 5b790641e7a54d74e04252b9f1e9e8a2cd176da0 Mon Sep 17 00:00:00 2001 From: Chunhao Zhang <1226577187zch@gmail.com> Date: Sun, 17 May 2026 15:13:28 +0800 Subject: [PATCH] feat: make keyboard shortcuts configurable --- .../shared/src/keyboard-shortcuts.test.ts | 246 +++++++ .../packages/shared/src/keyboard-shortcuts.ts | 663 ++++++++++++++++++ .../packages/shared/src/window-api.d.ts | 2 + collab-electron/src/main/index.ts | 187 ++--- collab-electron/src/preload/universal.ts | 9 + collab-electron/src/windows/nav/src/App.tsx | 60 +- .../src/windows/settings/src/App.tsx | 341 ++++++++- .../src/windows/shell/src/renderer.js | 89 ++- 8 files changed, 1468 insertions(+), 129 deletions(-) create mode 100644 collab-electron/packages/shared/src/keyboard-shortcuts.test.ts create mode 100644 collab-electron/packages/shared/src/keyboard-shortcuts.ts diff --git a/collab-electron/packages/shared/src/keyboard-shortcuts.test.ts b/collab-electron/packages/shared/src/keyboard-shortcuts.test.ts new file mode 100644 index 00000000..b0c1cb84 --- /dev/null +++ b/collab-electron/packages/shared/src/keyboard-shortcuts.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, test } from "bun:test"; +import { + findKeyboardShortcutAction, + findKeyboardShortcutConflicts, + formatKeyboardShortcutBindings, + getDefaultKeyboardShortcutBindings, + getEffectiveKeyboardShortcutBindings, + keyboardShortcutBindingFromInput, + keyboardShortcutBindingToElectronAccelerator, + normalizeKeyboardShortcutOverrides, + validateKeyboardShortcutBinding, + withKeyboardShortcutOverride, + type KeyboardShortcutBinding, +} from "./keyboard-shortcuts"; + +function binding( + code: string, + key: string, + modifiers: KeyboardShortcutBinding["modifiers"], +): KeyboardShortcutBinding { + return { code, key, modifiers }; +} + +describe("keyboard shortcut defaults", () => { + test("uses platform-specific fullscreen defaults", () => { + expect(getDefaultKeyboardShortcutBindings( + "toggle-full-screen", + "darwin", + )).toEqual([ + binding("KeyF", "F", { control: true, meta: true }), + ]); + expect(getDefaultKeyboardShortcutBindings( + "toggle-full-screen", + "win32", + )).toEqual([binding("F11", "F11", {})]); + }); + + test("supports multiple default bindings for one action", () => { + expect(getDefaultKeyboardShortcutBindings( + "sidebar-files", + "darwin", + )).toEqual([ + binding("KeyB", "B", { cmdOrCtrl: true }), + binding("Backslash", "\\", { cmdOrCtrl: true }), + ]); + }); +}); + +describe("keyboard shortcut overrides", () => { + test("missing override falls back to defaults", () => { + expect(getEffectiveKeyboardShortcutBindings( + "focus-tile-left", + {}, + "darwin", + )).toEqual([binding("ArrowLeft", "ArrowLeft", { alt: true })]); + }); + + test("null override disables an action", () => { + const overrides = normalizeKeyboardShortcutOverrides({ + "focus-tile-left": null, + }); + expect(getEffectiveKeyboardShortcutBindings( + "focus-tile-left", + overrides, + "darwin", + )).toEqual([]); + }); + + test("array override replaces defaults", () => { + const overrides = normalizeKeyboardShortcutOverrides({ + "focus-tile-left": [ + binding("ArrowLeft", "ArrowLeft", { alt: true, shift: true }), + ], + }); + expect(getEffectiveKeyboardShortcutBindings( + "focus-tile-left", + overrides, + "darwin", + )).toEqual([ + binding("ArrowLeft", "ArrowLeft", { alt: true, shift: true }), + ]); + }); + + test("withKeyboardShortcutOverride can reset to default", () => { + const overrides = withKeyboardShortcutOverride( + { "focus-tile-left": null }, + "focus-tile-left", + undefined, + ); + expect(Object.hasOwn(overrides, "focus-tile-left")).toBe(false); + }); +}); + +describe("keyboard shortcut matching", () => { + test("matches configured Option+Shift+Arrow instead of default Option+Arrow", () => { + const overrides = normalizeKeyboardShortcutOverrides({ + "focus-tile-left": [ + binding("ArrowLeft", "ArrowLeft", { alt: true, shift: true }), + ], + }); + expect(findKeyboardShortcutAction({ + type: "keyDown", + code: "ArrowLeft", + key: "ArrowLeft", + alt: true, + }, overrides, "darwin")).toBeNull(); + expect(findKeyboardShortcutAction({ + type: "keyDown", + code: "ArrowLeft", + key: "ArrowLeft", + alt: true, + shift: true, + }, overrides, "darwin")).toBe("focus-tile-left"); + }); + + test("does not match disabled actions", () => { + const overrides = normalizeKeyboardShortcutOverrides({ + "focus-tile-left": null, + }); + expect(findKeyboardShortcutAction({ + type: "keyDown", + code: "ArrowLeft", + key: "ArrowLeft", + alt: true, + }, overrides, "darwin")).toBeNull(); + }); + + test("matches DOM KeyboardEvent style modifier names", () => { + expect(findKeyboardShortcutAction({ + type: "keydown", + code: "KeyK", + key: "k", + metaKey: true, + }, {}, "darwin")).toBe("focus-file-search"); + }); + + test("matches CmdOrCtrl to the platform primary modifier only", () => { + expect(findKeyboardShortcutAction({ + type: "keydown", + code: "KeyK", + key: "k", + ctrlKey: true, + }, {}, "darwin")).toBeNull(); + expect(findKeyboardShortcutAction({ + type: "keydown", + code: "KeyK", + key: "k", + metaKey: true, + ctrlKey: true, + }, {}, "darwin")).toBeNull(); + expect(findKeyboardShortcutAction({ + type: "keydown", + code: "KeyK", + key: "k", + ctrlKey: true, + }, {}, "win32")).toBe("focus-file-search"); + }); +}); + +describe("keyboard shortcut recording", () => { + test("creates a binding from a keyboard input", () => { + expect(keyboardShortcutBindingFromInput({ + code: "ArrowLeft", + key: "ArrowLeft", + altKey: true, + shiftKey: true, + })).toEqual(binding("ArrowLeft", "ArrowLeft", { + alt: true, + shift: true, + })); + }); + + test("ignores pure modifier input", () => { + expect(keyboardShortcutBindingFromInput({ + code: "AltLeft", + key: "Alt", + altKey: true, + })).toBeNull(); + }); + + test("rejects bare printable keys but allows function keys", () => { + expect(validateKeyboardShortcutBinding( + binding("KeyK", "k", {}), + )).toBe("Use at least one modifier key."); + expect(validateKeyboardShortcutBinding( + binding("F11", "F11", {}), + )).toBeNull(); + }); +}); + +describe("keyboard shortcut display and conflicts", () => { + test("formats bindings for macOS and Windows", () => { + const bindings = [ + binding("ArrowLeft", "ArrowLeft", { alt: true, shift: true }), + ]; + expect(formatKeyboardShortcutBindings(bindings, "darwin")).toEqual([ + "⌥ ⇧ ←", + ]); + expect(formatKeyboardShortcutBindings(bindings, "win32")).toEqual([ + "Alt+Shift+←", + ]); + }); + + test("detects duplicate effective bindings", () => { + const conflicts = findKeyboardShortcutConflicts( + binding("KeyK", "K", { cmdOrCtrl: true }), + "toggle-settings", + {}, + "darwin", + ); + expect(conflicts.map((conflict) => conflict.id)).toEqual([ + "focus-file-search", + ]); + }); + + test("treats recorded command keys as equivalent to CmdOrCtrl defaults", () => { + const conflicts = findKeyboardShortcutConflicts( + binding("KeyK", "k", { meta: true }), + "toggle-settings", + {}, + "darwin", + ); + expect(conflicts.map((conflict) => conflict.id)).toEqual([ + "focus-file-search", + ]); + }); + + test("does not collapse Ctrl+Cmd into a plain CmdOrCtrl binding", () => { + const conflicts = findKeyboardShortcutConflicts( + binding("KeyK", "k", { meta: true, control: true }), + "toggle-settings", + {}, + "darwin", + ); + expect(conflicts.map((conflict) => conflict.id)).toEqual([]); + }); + + test("converts menu-friendly bindings to Electron accelerators", () => { + expect(keyboardShortcutBindingToElectronAccelerator( + binding("KeyK", "K", { cmdOrCtrl: true }), + )).toBe("CommandOrControl+K"); + expect(keyboardShortcutBindingToElectronAccelerator( + binding("KeyF", "F", { control: true, meta: true }), + )).toBe("Ctrl+Command+F"); + }); +}); diff --git a/collab-electron/packages/shared/src/keyboard-shortcuts.ts b/collab-electron/packages/shared/src/keyboard-shortcuts.ts new file mode 100644 index 00000000..58e93ce4 --- /dev/null +++ b/collab-electron/packages/shared/src/keyboard-shortcuts.ts @@ -0,0 +1,663 @@ +export const KEYBOARD_SHORTCUTS_PREF = "keyboardShortcuts"; + +export type KeyboardShortcutPlatform = + | "darwin" + | "win32" + | "linux" + | "other"; + +export type KeyboardShortcutCategory = + | "Application" + | "Navigation" + | "Canvas" + | "View"; + +export type KeyboardShortcutActionId = + | "toggle-settings" + | "sidebar-files" + | "toggle-agent" + | "add-workspace" + | "focus-file-search" + | "new-tile" + | "close-tile" + | "focus-tile-left" + | "focus-tile-right" + | "focus-tile-up" + | "focus-tile-down" + | "zoom-in" + | "zoom-out" + | "zoom-reset" + | "toggle-full-screen"; + +export interface KeyboardShortcutModifiers { + cmdOrCtrl?: boolean; + shift?: boolean; + alt?: boolean; + control?: boolean; + meta?: boolean; +} + +export interface KeyboardShortcutBinding { + code: string; + key: string; + modifiers: KeyboardShortcutModifiers; +} + +export type KeyboardShortcutOverride = + | KeyboardShortcutBinding[] + | null; + +export type KeyboardShortcutOverrides = Partial< + Record +>; + +export interface KeyboardShortcutDefinition { + id: KeyboardShortcutActionId; + label: string; + category: KeyboardShortcutCategory; + defaultBindings: readonly KeyboardShortcutBinding[]; + defaultBindingsByPlatform?: Partial< + Record + >; +} + +export interface KeyboardShortcutInputLike { + type?: string; + code?: string; + key?: string; + alt?: boolean; + meta?: boolean; + control?: boolean; + shift?: boolean; + altKey?: boolean; + metaKey?: boolean; + ctrlKey?: boolean; + shiftKey?: boolean; + isAutoRepeat?: boolean; + repeat?: boolean; +} + +const MODIFIER_KEYS = new Set([ + "Alt", + "AltGraph", + "Control", + "Meta", + "Shift", +]); + +function shortcut( + code: string, + key: string, + modifiers: KeyboardShortcutModifiers = {}, +): KeyboardShortcutBinding { + return { code, key, modifiers }; +} + +export const KEYBOARD_SHORTCUT_DEFINITIONS: readonly KeyboardShortcutDefinition[] = [ + { + id: "toggle-settings", + label: "Settings", + category: "Application", + defaultBindings: [shortcut("Comma", ",", { cmdOrCtrl: true })], + }, + { + id: "add-workspace", + label: "Open Workspace", + category: "Application", + defaultBindings: [ + shortcut("KeyO", "O", { cmdOrCtrl: true, shift: true }), + ], + }, + { + id: "focus-file-search", + label: "Find", + category: "Navigation", + defaultBindings: [shortcut("KeyK", "K", { cmdOrCtrl: true })], + }, + { + id: "sidebar-files", + label: "Toggle Navigator", + category: "Navigation", + defaultBindings: [ + shortcut("KeyB", "B", { cmdOrCtrl: true }), + shortcut("Backslash", "\\", { cmdOrCtrl: true }), + ], + }, + { + id: "toggle-agent", + label: "Toggle Agent", + category: "Navigation", + defaultBindings: [ + shortcut("KeyB", "B", { cmdOrCtrl: true, alt: true }), + ], + }, + { + id: "new-tile", + label: "New Tile", + category: "Canvas", + defaultBindings: [shortcut("KeyN", "N", { cmdOrCtrl: true })], + }, + { + id: "close-tile", + label: "Close Tile", + category: "Canvas", + defaultBindings: [shortcut("KeyW", "W", { cmdOrCtrl: true })], + }, + { + id: "focus-tile-left", + label: "Focus Tile Left", + category: "Canvas", + defaultBindings: [shortcut("ArrowLeft", "ArrowLeft", { alt: true })], + }, + { + id: "focus-tile-right", + label: "Focus Tile Right", + category: "Canvas", + defaultBindings: [shortcut("ArrowRight", "ArrowRight", { alt: true })], + }, + { + id: "focus-tile-up", + label: "Focus Tile Up", + category: "Canvas", + defaultBindings: [shortcut("ArrowUp", "ArrowUp", { alt: true })], + }, + { + id: "focus-tile-down", + label: "Focus Tile Down", + category: "Canvas", + defaultBindings: [shortcut("ArrowDown", "ArrowDown", { alt: true })], + }, + { + id: "zoom-in", + label: "Zoom In", + category: "View", + defaultBindings: [shortcut("Equal", "=", { cmdOrCtrl: true })], + }, + { + id: "zoom-out", + label: "Zoom Out", + category: "View", + defaultBindings: [shortcut("Minus", "-", { cmdOrCtrl: true })], + }, + { + id: "zoom-reset", + label: "Actual Size", + category: "View", + defaultBindings: [shortcut("Digit0", "0", { cmdOrCtrl: true })], + }, + { + id: "toggle-full-screen", + label: "Toggle Full Screen", + category: "View", + defaultBindings: [shortcut("F11", "F11")], + defaultBindingsByPlatform: { + darwin: [ + shortcut("KeyF", "F", { control: true, meta: true }), + ], + }, + }, +]; + +export const KEYBOARD_SHORTCUT_ACTION_IDS = + KEYBOARD_SHORTCUT_DEFINITIONS.map((definition) => definition.id); + +const KEYBOARD_SHORTCUT_DEFINITION_BY_ID = new Map( + KEYBOARD_SHORTCUT_DEFINITIONS.map((definition) => [ + definition.id, + definition, + ]), +); + +export function normalizeShortcutKey( + key: string | null | undefined, +): string | null { + if (!key) return null; + return key.length === 1 ? key.toLowerCase() : key; +} + +export function normalizeKeyboardShortcutPlatform( + platform: string | undefined, +): KeyboardShortcutPlatform { + if (platform === "darwin" || platform === "win32" || platform === "linux") { + return platform; + } + return "other"; +} + +function primaryModifierForPlatform( + platform: string | undefined, +): "control" | "meta" { + return normalizeKeyboardShortcutPlatform(platform) === "darwin" + ? "meta" + : "control"; +} + +export function isKeyboardShortcutActionId( + value: string, +): value is KeyboardShortcutActionId { + return KEYBOARD_SHORTCUT_DEFINITION_BY_ID.has( + value as KeyboardShortcutActionId, + ); +} + +export function getKeyboardShortcutDefinition( + actionId: KeyboardShortcutActionId, +): KeyboardShortcutDefinition { + const definition = KEYBOARD_SHORTCUT_DEFINITION_BY_ID.get(actionId); + if (!definition) { + throw new Error(`Unknown keyboard shortcut action: ${actionId}`); + } + return definition; +} + +function cloneBinding( + binding: KeyboardShortcutBinding, +): KeyboardShortcutBinding { + return { + code: binding.code, + key: binding.key, + modifiers: { ...binding.modifiers }, + }; +} + +function cloneBindings( + bindings: readonly KeyboardShortcutBinding[], +): KeyboardShortcutBinding[] { + return bindings.map(cloneBinding); +} + +export function getDefaultKeyboardShortcutBindings( + actionId: KeyboardShortcutActionId, + platform: string | undefined, +): KeyboardShortcutBinding[] { + const normalizedPlatform = normalizeKeyboardShortcutPlatform(platform); + const definition = getKeyboardShortcutDefinition(actionId); + const platformDefault = + definition.defaultBindingsByPlatform?.[normalizedPlatform]; + return cloneBindings(platformDefault ?? definition.defaultBindings); +} + +export function getEffectiveKeyboardShortcutBindings( + actionId: KeyboardShortcutActionId, + overrides: KeyboardShortcutOverrides, + platform: string | undefined, +): KeyboardShortcutBinding[] { + if (Object.hasOwn(overrides, actionId)) { + const override = overrides[actionId]; + return override === null ? [] : cloneBindings(override ?? []); + } + return getDefaultKeyboardShortcutBindings(actionId, platform); +} + +export function getEffectiveKeyboardShortcutMap( + overrides: KeyboardShortcutOverrides, + platform: string | undefined, +): Record { + const entries = KEYBOARD_SHORTCUT_ACTION_IDS.map((actionId) => [ + actionId, + getEffectiveKeyboardShortcutBindings(actionId, overrides, platform), + ] as const); + return Object.fromEntries(entries) as Record< + KeyboardShortcutActionId, + KeyboardShortcutBinding[] + >; +} + +function readBoolean(value: unknown): boolean | undefined { + return value === true ? true : undefined; +} + +export function normalizeKeyboardShortcutBinding( + value: unknown, +): KeyboardShortcutBinding | null { + if (!value || typeof value !== "object") return null; + const record = value as Record; + if (typeof record.code !== "string" || typeof record.key !== "string") { + return null; + } + const rawModifiers = record.modifiers && typeof record.modifiers === "object" + ? record.modifiers as Record + : {}; + const modifiers: KeyboardShortcutModifiers = {}; + const cmdOrCtrl = readBoolean(rawModifiers.cmdOrCtrl); + const shift = readBoolean(rawModifiers.shift); + const alt = readBoolean(rawModifiers.alt); + const control = readBoolean(rawModifiers.control); + const meta = readBoolean(rawModifiers.meta); + if (cmdOrCtrl) modifiers.cmdOrCtrl = cmdOrCtrl; + if (shift) modifiers.shift = shift; + if (alt) modifiers.alt = alt; + if (control) modifiers.control = control; + if (meta) modifiers.meta = meta; + return { + code: record.code, + key: record.key, + modifiers, + }; +} + +export function normalizeKeyboardShortcutOverrides( + value: unknown, +): KeyboardShortcutOverrides { + if (!value || typeof value !== "object") return {}; + const record = value as Record; + const overrides: KeyboardShortcutOverrides = {}; + for (const actionId of KEYBOARD_SHORTCUT_ACTION_IDS) { + if (!Object.hasOwn(record, actionId)) continue; + const rawOverride = record[actionId]; + if (rawOverride === null) { + overrides[actionId] = null; + continue; + } + const rawBindings = Array.isArray(rawOverride) + ? rawOverride + : [rawOverride]; + const bindings = rawBindings + .map(normalizeKeyboardShortcutBinding) + .filter((binding): binding is KeyboardShortcutBinding => Boolean(binding)); + if (bindings.length > 0) overrides[actionId] = bindings; + } + return overrides; +} + +export function withKeyboardShortcutOverride( + overrides: KeyboardShortcutOverrides, + actionId: KeyboardShortcutActionId, + override: KeyboardShortcutOverride | undefined, +): KeyboardShortcutOverrides { + const next: KeyboardShortcutOverrides = { ...overrides }; + if (override === undefined) { + delete next[actionId]; + } else { + next[actionId] = override === null ? null : cloneBindings(override); + } + return next; +} + +function inputTypeIsKeyDown(input: KeyboardShortcutInputLike): boolean { + return !input.type || input.type === "keyDown" || input.type === "keydown"; +} + +export function isKeyboardShortcutAutoRepeat( + input: KeyboardShortcutInputLike, +): boolean { + return input.isAutoRepeat === true || input.repeat === true; +} + +function inputHasModifier( + input: KeyboardShortcutInputLike, + electronName: "alt" | "meta" | "control" | "shift", + domName: "altKey" | "metaKey" | "ctrlKey" | "shiftKey", +): boolean { + return input[electronName] === true || input[domName] === true; +} + +function getInputModifierState( + input: KeyboardShortcutInputLike, +): Required { + return { + cmdOrCtrl: inputHasModifier(input, "meta", "metaKey") || + inputHasModifier(input, "control", "ctrlKey"), + shift: inputHasModifier(input, "shift", "shiftKey"), + alt: inputHasModifier(input, "alt", "altKey"), + control: inputHasModifier(input, "control", "ctrlKey"), + meta: inputHasModifier(input, "meta", "metaKey"), + }; +} + +function bindingModifierState( + binding: KeyboardShortcutBinding, +): Required { + return { + cmdOrCtrl: binding.modifiers.cmdOrCtrl === true, + shift: binding.modifiers.shift === true, + alt: binding.modifiers.alt === true, + control: binding.modifiers.control === true, + meta: binding.modifiers.meta === true, + }; +} + +function concreteBindingModifierState( + binding: KeyboardShortcutBinding, + platform: string | undefined, +): Required { + const state = bindingModifierState(binding); + state.cmdOrCtrl = false; + if (binding.modifiers.cmdOrCtrl === true) { + state[primaryModifierForPlatform(platform)] = true; + } + return state; +} + +function modifiersMatchInput( + binding: KeyboardShortcutBinding, + input: KeyboardShortcutInputLike, + platform: string | undefined, +): boolean { + const expected = concreteBindingModifierState(binding, platform); + const actual = getInputModifierState(input); + + return expected.shift === actual.shift && + expected.alt === actual.alt && + expected.control === actual.control && + expected.meta === actual.meta; +} + +function keyMatchesInput( + binding: KeyboardShortcutBinding, + input: KeyboardShortcutInputLike, +): boolean { + if (input.code && binding.code === input.code) return true; + return normalizeShortcutKey(binding.key) === normalizeShortcutKey(input.key); +} + +export function keyboardShortcutBindingMatchesInput( + binding: KeyboardShortcutBinding, + input: KeyboardShortcutInputLike, + platform?: string, +): boolean { + return inputTypeIsKeyDown(input) && + keyMatchesInput(binding, input) && + modifiersMatchInput(binding, input, platform); +} + +export function findKeyboardShortcutAction( + input: KeyboardShortcutInputLike, + overrides: KeyboardShortcutOverrides, + platform: string | undefined, +): KeyboardShortcutActionId | null { + for (const actionId of KEYBOARD_SHORTCUT_ACTION_IDS) { + const bindings = getEffectiveKeyboardShortcutBindings( + actionId, + overrides, + platform, + ); + if (bindings.some((binding) => + keyboardShortcutBindingMatchesInput(binding, input, platform) + )) { + return actionId; + } + } + return null; +} + +export function keyboardShortcutBindingFromInput( + input: KeyboardShortcutInputLike, +): KeyboardShortcutBinding | null { + if (!input.code || !input.key || MODIFIER_KEYS.has(input.key)) { + return null; + } + const modifiers: KeyboardShortcutModifiers = {}; + const state = getInputModifierState(input); + if (state.shift) modifiers.shift = true; + if (state.alt) modifiers.alt = true; + if (state.control) modifiers.control = true; + if (state.meta) modifiers.meta = true; + return { + code: input.code, + key: input.key, + modifiers, + }; +} + +function hasAnyModifier(binding: KeyboardShortcutBinding): boolean { + const modifiers = binding.modifiers; + return modifiers.cmdOrCtrl === true || + modifiers.shift === true || + modifiers.alt === true || + modifiers.control === true || + modifiers.meta === true; +} + +function isFunctionKey(binding: KeyboardShortcutBinding): boolean { + return /^F(?:[1-9]|1[0-9]|2[0-4])$/.test(binding.code) || + /^F(?:[1-9]|1[0-9]|2[0-4])$/.test(binding.key); +} + +export function validateKeyboardShortcutBinding( + binding: KeyboardShortcutBinding, +): string | null { + if (!binding.code || !binding.key) return "Press a shortcut."; + if (MODIFIER_KEYS.has(binding.key)) return "Press a non-modifier key."; + if (!hasAnyModifier(binding) && !isFunctionKey(binding)) { + return "Use at least one modifier key."; + } + return null; +} + +function concreteModifierStatesEqual( + a: Required, + b: Required, +): boolean { + return a.shift === b.shift && + a.alt === b.alt && + a.control === b.control && + a.meta === b.meta; +} + +export function keyboardShortcutBindingsEqual( + a: KeyboardShortcutBinding, + b: KeyboardShortcutBinding, + platform?: string, +): boolean { + const sameKey = a.code === b.code || + normalizeShortcutKey(a.key) === normalizeShortcutKey(b.key); + return sameKey && concreteModifierStatesEqual( + concreteBindingModifierState(a, platform), + concreteBindingModifierState(b, platform), + ); +} + +export function findKeyboardShortcutConflicts( + binding: KeyboardShortcutBinding, + actionId: KeyboardShortcutActionId, + overrides: KeyboardShortcutOverrides, + platform: string | undefined, +): KeyboardShortcutDefinition[] { + return KEYBOARD_SHORTCUT_DEFINITIONS.filter((definition) => { + if (definition.id === actionId) return false; + const bindings = getEffectiveKeyboardShortcutBindings( + definition.id, + overrides, + platform, + ); + return bindings.some((candidate) => + keyboardShortcutBindingsEqual(candidate, binding, platform) + ); + }); +} + +function displayKey(binding: KeyboardShortcutBinding): string { + const code = binding.code; + if (code === "ArrowLeft") return "←"; + if (code === "ArrowRight") return "→"; + if (code === "ArrowUp") return "↑"; + if (code === "ArrowDown") return "↓"; + if (code === "Comma") return ","; + if (code === "Backslash") return "\\"; + if (code === "Backquote") return "`"; + if (code === "Equal") return "="; + if (code === "Minus") return "-"; + if (/^Digit\d$/.test(code)) return code.slice(5); + if (/^Key[A-Z]$/.test(code)) return code.slice(3); + if (/^F(?:[1-9]|1[0-9]|2[0-4])$/.test(code)) return code; + if (binding.key.length === 1) return binding.key.toUpperCase(); + return binding.key; +} + +export function formatKeyboardShortcutBinding( + binding: KeyboardShortcutBinding, + platform: string | undefined, +): string { + const normalizedPlatform = normalizeKeyboardShortcutPlatform(platform); + const modifiers = binding.modifiers; + const key = displayKey(binding); + if (normalizedPlatform === "darwin") { + const parts = []; + if (modifiers.control) parts.push("⌃"); + if (modifiers.alt) parts.push("⌥"); + if (modifiers.shift) parts.push("⇧"); + if (modifiers.meta || modifiers.cmdOrCtrl) parts.push("⌘"); + return [...parts, key].join(" "); + } + const parts = []; + if (modifiers.cmdOrCtrl || modifiers.control) parts.push("Ctrl"); + if (modifiers.alt) parts.push("Alt"); + if (modifiers.shift) parts.push("Shift"); + if (modifiers.meta) parts.push("Meta"); + return [...parts, key].join("+"); +} + +export function formatKeyboardShortcutBindings( + bindings: readonly KeyboardShortcutBinding[], + platform: string | undefined, +): string[] { + return bindings.map((binding) => + formatKeyboardShortcutBinding(binding, platform) + ); +} + +function keyToElectronAccelerator(binding: KeyboardShortcutBinding): string | null { + const code = binding.code; + if (/^Key[A-Z]$/.test(code)) return code.slice(3); + if (/^Digit\d$/.test(code)) return code.slice(5); + if (/^F(?:[1-9]|1[0-9]|2[0-4])$/.test(code)) return code; + const map: Record = { + ArrowLeft: "Left", + ArrowRight: "Right", + ArrowUp: "Up", + ArrowDown: "Down", + Backquote: "`", + Backslash: "\\", + BracketLeft: "[", + BracketRight: "]", + Comma: ",", + Equal: "=", + Minus: "-", + Period: ".", + Quote: "'", + Semicolon: ";", + Slash: "/", + Space: "Space", + Tab: "Tab", + Enter: "Enter", + Escape: "Esc", + }; + if (map[code]) return map[code]; + return binding.key.length === 1 ? binding.key.toUpperCase() : null; +} + +export function keyboardShortcutBindingToElectronAccelerator( + binding: KeyboardShortcutBinding, +): string | null { + const key = keyToElectronAccelerator(binding); + if (!key) return null; + const modifiers = binding.modifiers; + const parts = []; + if (modifiers.cmdOrCtrl) parts.push("CommandOrControl"); + if (modifiers.control) parts.push("Ctrl"); + if (modifiers.meta) parts.push("Command"); + if (modifiers.alt) parts.push("Alt"); + if (modifiers.shift) parts.push("Shift"); + return [...parts, key].join("+"); +} diff --git a/collab-electron/packages/shared/src/window-api.d.ts b/collab-electron/packages/shared/src/window-api.d.ts index 9e254c75..0b328b99 100644 --- a/collab-electron/packages/shared/src/window-api.d.ts +++ b/collab-electron/packages/shared/src/window-api.d.ts @@ -119,6 +119,8 @@ export interface CollabApi { getDeviceId: () => Promise; getPref: (key: string) => Promise; setPref: (key: string, value: unknown) => Promise; + setKeyboardShortcutRecording: (recording: boolean) => void; + onPrefChanged: (cb: (key: string, value: unknown) => void) => Unsubscribe; listTerminalTargets: () => Promise; getWorkspacePref: (key: string, workspacePath: string) => Promise; setWorkspacePref: ( diff --git a/collab-electron/src/main/index.ts b/collab-electron/src/main/index.ts index 23bb006c..f498b73f 100644 --- a/collab-electron/src/main/index.ts +++ b/collab-electron/src/main/index.ts @@ -37,6 +37,15 @@ import { import * as watcher from "./watcher"; import * as gitReplay from "./git-replay"; import { DISABLE_GIT_REPLAY } from "@collab/shared/replay-types"; +import { + KEYBOARD_SHORTCUTS_PREF, + findKeyboardShortcutAction, + getEffectiveKeyboardShortcutBindings, + isKeyboardShortcutAutoRepeat, + keyboardShortcutBindingToElectronAccelerator, + normalizeKeyboardShortcutOverrides, + type KeyboardShortcutActionId, +} from "@collab/shared/keyboard-shortcuts"; import * as pty from "./pty"; import { updateManager, setupUpdateIPC } from "./updater"; import { DEV_WORKTREE_ID } from "./paths"; @@ -166,75 +175,80 @@ function debouncedSaveWindowState(): void { }, 500); } -function sendShortcut(action: string): void { +const shortcutRecordingWebContents = new Set(); + +function sendShortcut(action: KeyboardShortcutActionId): void { mainWindow?.webContents.send("shell:shortcut", action); } -const cmdOrCtrl = (input: Electron.Input): boolean => - input.meta || input.control; -const shiftCmdOrCtrl = (input: Electron.Input): boolean => - input.shift && (input.meta || input.control); -const altCmdOrCtrl = (input: Electron.Input): boolean => - input.alt && (input.meta || input.control); -const ctrlOnly = (input: Electron.Input): boolean => - input.control && !input.meta; -const altOnly = (input: Electron.Input): boolean => - input.alt && !input.meta && !input.control && !input.shift; - -interface ShortcutEntry { - modifier: (input: Electron.Input) => boolean; - action: string; +function getKeyboardShortcutOverrides() { + return normalizeKeyboardShortcutOverrides( + getPref(config, KEYBOARD_SHORTCUTS_PREF), + ); } -const TOGGLE_SHORTCUTS: Record = { - KeyB: [ - { modifier: altCmdOrCtrl, action: "toggle-agent" }, - { modifier: cmdOrCtrl, action: "sidebar-files" }, - ], - Backslash: [{ modifier: cmdOrCtrl, action: "sidebar-files" }], - Comma: [{ modifier: cmdOrCtrl, action: "toggle-settings" }], - KeyO: [{ modifier: shiftCmdOrCtrl, action: "add-workspace" }], - KeyK: [{ modifier: cmdOrCtrl, action: "focus-file-search" }], - KeyN: [{ modifier: cmdOrCtrl, action: "new-tile" }], - KeyW: [{ modifier: cmdOrCtrl, action: "close-tile" }], - ArrowRight: [{ modifier: altOnly, action: "focus-tile-right" }], - ArrowLeft: [{ modifier: altOnly, action: "focus-tile-left" }], - ArrowUp: [{ modifier: altOnly, action: "focus-tile-up" }], - ArrowDown: [{ modifier: altOnly, action: "focus-tile-down" }], -}; +function runKeyboardShortcutAction(action: KeyboardShortcutActionId): void { + if (action === "zoom-in") { + applyZoomToAll(globalZoomLevel + 0.25); + } else if (action === "zoom-out") { + applyZoomToAll(globalZoomLevel - 0.25); + } else if (action === "zoom-reset") { + applyZoomToAll(0); + } else if (action === "toggle-full-screen") { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.setFullScreen(!mainWindow.isFullScreen()); + } + } else { + sendShortcut(action); + } +} -const TOGGLE_SHORTCUT_KEYS: Record = { - ",": TOGGLE_SHORTCUTS.Comma!, - o: TOGGLE_SHORTCUTS.KeyO!, - k: TOGGLE_SHORTCUTS.KeyK!, - b: TOGGLE_SHORTCUTS.KeyB!, - n: TOGGLE_SHORTCUTS.KeyN!, - w: TOGGLE_SHORTCUTS.KeyW!, -}; +function resolveKeyboardShortcut( + input: Electron.Input, +): KeyboardShortcutActionId | null { + return findKeyboardShortcutAction( + input, + getKeyboardShortcutOverrides(), + process.platform, + ); +} -function normalizeShortcutKey(key: string | undefined): string | null { - if (!key) return null; - return key.length === 1 ? key.toLowerCase() : key; +function getKeyboardShortcutAccelerator( + action: KeyboardShortcutActionId, +): string | undefined { + const [binding] = getEffectiveKeyboardShortcutBindings( + action, + getKeyboardShortcutOverrides(), + process.platform, + ); + if (!binding) return undefined; + return keyboardShortcutBindingToElectronAccelerator(binding) ?? undefined; } -function resolveToggleShortcut( - input: Electron.Input, -): ShortcutEntry | undefined { - const candidates = TOGGLE_SHORTCUTS[input.code] - ?? (normalizeShortcutKey(input.key) - ? TOGGLE_SHORTCUT_KEYS[normalizeShortcutKey(input.key)!] - : undefined); - return candidates?.find((s) => s.modifier(input)); +function menuShortcutOptions( + action: KeyboardShortcutActionId, +): Pick { + const accelerator = getKeyboardShortcutAccelerator(action); + return accelerator + ? { accelerator, registerAccelerator: false } + : { registerAccelerator: false }; } function attachShortcutListener(target: WebContents): void { + target.once("destroyed", () => { + shortcutRecordingWebContents.delete(target.id); + }); + target.on("before-input-event", (event, input) => { if (input.type !== "keyDown") return; + if (shortcutRecordingWebContents.has(target.id)) return; - const toggle = resolveToggleShortcut(input); - if (toggle) { + const action = resolveKeyboardShortcut(input); + if (action) { event.preventDefault(); - if (!input.isAutoRepeat) sendShortcut(toggle.action); + if (!isKeyboardShortcutAutoRepeat(input)) { + runKeyboardShortcutAction(action); + } } }); } @@ -253,6 +267,7 @@ function attachBrowserShortcuts( ): void { wc.on("before-input-event", (event, input) => { if (input.type !== "keyDown") return; + if (resolveKeyboardShortcut(input)) return; const cmd = input.meta || input.control; if (!cmd) { if (input.key === "Escape" && wc.isLoading()) { @@ -309,7 +324,6 @@ function applyZoomToAll(level: number): void { function buildAppMenu(): void { const isMac = process.platform === "darwin"; - const fullScreenAccelerator = isMac ? "Ctrl+Cmd+F" : "F11"; const template: Electron.MenuItemConstructorOptions[] = [ ...(isMac @@ -321,9 +335,8 @@ function buildAppMenu(): void { { type: "separator" as const }, { label: "Settings\u2026", - accelerator: "CommandOrControl+,", - registerAccelerator: false, - click: () => sendShortcut("toggle-settings"), + ...menuShortcutOptions("toggle-settings"), + click: () => runKeyboardShortcutAction("toggle-settings"), } as Electron.MenuItemConstructorOptions, { type: "separator" as const }, { role: "services" as const }, @@ -342,22 +355,19 @@ function buildAppMenu(): void { submenu: [ { label: "New Tile", - accelerator: "CommandOrControl+N", - registerAccelerator: false, - click: () => sendShortcut("new-tile"), + ...menuShortcutOptions("new-tile"), + click: () => runKeyboardShortcutAction("new-tile"), }, { label: "Close Tile", - accelerator: "CommandOrControl+W", - registerAccelerator: false, - click: () => sendShortcut("close-tile"), + ...menuShortcutOptions("close-tile"), + click: () => runKeyboardShortcutAction("close-tile"), }, { type: "separator" }, { label: "Open Workspace\u2026", - accelerator: "CommandOrControl+Shift+O", - registerAccelerator: false, - click: () => sendShortcut("add-workspace"), + ...menuShortcutOptions("add-workspace"), + click: () => runKeyboardShortcutAction("add-workspace"), }, ], }, @@ -374,9 +384,8 @@ function buildAppMenu(): void { { type: "separator" }, { label: "Find", - accelerator: "CommandOrControl+K", - registerAccelerator: false, - click: () => sendShortcut("focus-file-search"), + ...menuShortcutOptions("focus-file-search"), + click: () => runKeyboardShortcutAction("focus-file-search"), }, ], }, @@ -385,38 +394,36 @@ function buildAppMenu(): void { submenu: [ { label: "Toggle Files", - accelerator: "CommandOrControl+B", - registerAccelerator: false, - click: () => sendShortcut("sidebar-files"), + ...menuShortcutOptions("sidebar-files"), + click: () => runKeyboardShortcutAction("sidebar-files"), }, { label: "Toggle Agent", - accelerator: "CommandOrControl+Alt+B", - registerAccelerator: false, - click: () => sendShortcut("toggle-agent"), + ...menuShortcutOptions("toggle-agent"), + click: () => runKeyboardShortcutAction("toggle-agent"), }, { type: "separator" }, { label: "Zoom In", - accelerator: "CommandOrControl+=", - click: () => applyZoomToAll(globalZoomLevel + 0.25), + ...menuShortcutOptions("zoom-in"), + click: () => runKeyboardShortcutAction("zoom-in"), }, { label: "Zoom Out", - accelerator: "CommandOrControl+-", - click: () => applyZoomToAll(globalZoomLevel - 0.25), + ...menuShortcutOptions("zoom-out"), + click: () => runKeyboardShortcutAction("zoom-out"), }, { label: "Actual Size", - accelerator: "CommandOrControl+0", - click: () => applyZoomToAll(0), + ...menuShortcutOptions("zoom-reset"), + click: () => runKeyboardShortcutAction("zoom-reset"), }, { type: "separator" }, { role: "toggleDevTools" }, { label: "Toggle Full Screen", - accelerator: fullScreenAccelerator, - click: (_, win) => win?.setFullScreen(!win.isFullScreen()), + ...menuShortcutOptions("toggle-full-screen"), + click: () => runKeyboardShortcutAction("toggle-full-screen"), }, ], }, @@ -557,12 +564,26 @@ ipcMain.handle( "pref:set", (_event, key: string, value: unknown) => { setPref(config, key, value); + if (key === KEYBOARD_SHORTCUTS_PREF) { + buildAppMenu(); + } if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send("pref:changed", key, value); } }, ); +ipcMain.on( + "keyboard-shortcut-recording:set", + (event, recording: boolean) => { + if (recording) { + shortcutRecordingWebContents.add(event.sender.id); + } else { + shortcutRecordingWebContents.delete(event.sender.id); + } + }, +); + ipcMain.handle( "terminal:list-targets", () => listTerminalTargets(), diff --git a/collab-electron/src/preload/universal.ts b/collab-electron/src/preload/universal.ts index 030bb989..768a3225 100644 --- a/collab-electron/src/preload/universal.ts +++ b/collab-electron/src/preload/universal.ts @@ -154,6 +154,15 @@ contextBridge.exposeInMainWorld("api", { getPref: (key: string) => ipcRenderer.invoke("pref:get", key), setPref: (key: string, value: unknown) => ipcRenderer.invoke("pref:set", key, value), + setKeyboardShortcutRecording: (recording: boolean) => { + ipcRenderer.send("keyboard-shortcut-recording:set", recording); + }, + onPrefChanged: (cb: (key: string, value: unknown) => void) => { + const handler = (_event: unknown, key: string, value: unknown) => + cb(key, value); + ipcRenderer.on("pref:changed", handler); + return () => ipcRenderer.removeListener("pref:changed", handler); + }, listTerminalTargets: () => ipcRenderer.invoke("terminal:list-targets"), getWorkspacePref: (key: string, workspacePath: string) => diff --git a/collab-electron/src/windows/nav/src/App.tsx b/collab-electron/src/windows/nav/src/App.tsx index b17793a4..66fe986e 100644 --- a/collab-electron/src/windows/nav/src/App.tsx +++ b/collab-electron/src/windows/nav/src/App.tsx @@ -29,6 +29,14 @@ import { isSubpath, parentPath, } from '@collab/shared/path-utils'; +import { + KEYBOARD_SHORTCUTS_PREF, + formatKeyboardShortcutBindings, + getEffectiveKeyboardShortcutBindings, + normalizeKeyboardShortcutOverrides, + type KeyboardShortcutActionId, + type KeyboardShortcutOverrides, +} from '@collab/shared/keyboard-shortcuts'; const PLATFORM = window.api.getPlatform(); @@ -39,6 +47,20 @@ const REVEAL_LABEL = ? 'Reveal in Explorer' : 'Reveal in File Manager'; +function formatShortcut( + actionId: KeyboardShortcutActionId, + overrides: KeyboardShortcutOverrides, +): string | undefined { + return formatKeyboardShortcutBindings( + getEffectiveKeyboardShortcutBindings( + actionId, + overrides, + PLATFORM, + ), + PLATFORM, + )[0]; +} + function ImportWebArticleModal({ folderPath, onClose, @@ -168,6 +190,8 @@ function ImportWebArticleModal({ } export default function App() { + const [keyboardShortcutOverrides, setKeyboardShortcutOverrides] = + useState({}); const treeSearchRef = useRef(null); const [workspacePaths, setWorkspacePaths] = @@ -469,6 +493,22 @@ export default function App() { }; }, []); + useEffect(() => { + window.api.getPref(KEYBOARD_SHORTCUTS_PREF) + .then((value) => { + setKeyboardShortcutOverrides( + normalizeKeyboardShortcutOverrides(value), + ); + }) + .catch(() => {}); + return window.api.onPrefChanged((key, value) => { + if (key !== KEYBOARD_SHORTCUTS_PREF) return; + setKeyboardShortcutOverrides( + normalizeKeyboardShortcutOverrides(value), + ); + }); + }, []); + useEffect(() => { return window.api.onFileRenamed( (oldPath, newPath) => { @@ -1315,6 +1355,11 @@ export default function App() { ); }, [focusActiveSearch, selectFile, getAllFlatItems]); + const addWorkspaceShortcut = formatShortcut( + 'add-workspace', + keyboardShortcutOverrides, + ); + return (
@@ -1348,7 +1393,10 @@ export default function App() { cycleSortMode } searchPlaceholder="Search" - searchShortcut={PLATFORM === "darwin" ? "Cmd+K" : "Ctrl+K"} + searchShortcut={formatShortcut( + 'focus-file-search', + keyboardShortcutOverrides, + )} onArrowNav={ navigateItems } @@ -1368,11 +1416,11 @@ export default function App() { } > + Add workspace - - {PLATFORM === "darwin" - ? "Shift+Cmd+O" - : "Shift+Ctrl+O"} - + {addWorkspaceShortcut && ( + + {addWorkspaceShortcut} + + )}
diff --git a/collab-electron/src/windows/settings/src/App.tsx b/collab-electron/src/windows/settings/src/App.tsx index b993666d..6754b732 100644 --- a/collab-electron/src/windows/settings/src/App.tsx +++ b/collab-electron/src/windows/settings/src/App.tsx @@ -9,12 +9,32 @@ import { Monitor, Terminal, } from "@phosphor-icons/react"; +import { + KEYBOARD_SHORTCUTS_PREF, + KEYBOARD_SHORTCUT_DEFINITIONS, + findKeyboardShortcutConflicts, + formatKeyboardShortcutBinding, + getDefaultKeyboardShortcutBindings, + getEffectiveKeyboardShortcutBindings, + keyboardShortcutBindingFromInput, + keyboardShortcutBindingsEqual, + normalizeKeyboardShortcutOverrides, + validateKeyboardShortcutBinding, + withKeyboardShortcutOverride, + type KeyboardShortcutActionId, + type KeyboardShortcutBinding, + type KeyboardShortcutCategory, + type KeyboardShortcutDefinition, + type KeyboardShortcutOverride, + type KeyboardShortcutOverrides, +} from "@collab/shared/keyboard-shortcuts"; type ThemeMode = "light" | "dark" | "system"; interface SettingsApi { getPref: (key: string) => Promise; setPref: (key: string, value: unknown) => Promise; + setKeyboardShortcutRecording: (recording: boolean) => void; listTerminalTargets: () => Promise + Disabled + + ); + } + return ( + + {keys.map((key) => {key})} + + ); +} + function ShortcutList({ items }: { items: { label: string; keys: string }[] }) { return (
@@ -309,6 +325,285 @@ function ShortcutList({ items }: { items: { label: string; keys: string }[] }) { ); } +const KEYBOARD_SHORTCUT_CATEGORIES: KeyboardShortcutCategory[] = [ + "Application", + "Navigation", + "Canvas", + "View", +]; + +function shortcutOverrideIsDefault( + actionId: KeyboardShortcutActionId, + overrides: KeyboardShortcutOverrides, +): boolean { + return !Object.hasOwn(overrides, actionId); +} + +function shortcutOverrideFromBinding( + binding: KeyboardShortcutBinding, +): KeyboardShortcutBinding[] { + return [binding]; +} + +function shortcutBindingsMatchDefault( + actionId: KeyboardShortcutActionId, + bindings: KeyboardShortcutBinding[], +): boolean { + const defaults = getDefaultKeyboardShortcutBindings(actionId, PLATFORM); + return bindings.length === defaults.length && bindings.every( + (binding, index) => keyboardShortcutBindingsEqual( + binding, + defaults[index]!, + PLATFORM, + ), + ); +} + +function ShortcutRow({ + definition, + overrides, + recording, + draftBinding, + error, + onRecord, + onClear, + onReset, +}: { + definition: KeyboardShortcutDefinition; + overrides: KeyboardShortcutOverrides; + recording: boolean; + draftBinding: KeyboardShortcutBinding | null; + error: string | null; + onRecord: (actionId: KeyboardShortcutActionId) => void; + onClear: (actionId: KeyboardShortcutActionId) => void; + onReset: (actionId: KeyboardShortcutActionId) => void; +}) { + const effectiveBindings = getEffectiveKeyboardShortcutBindings( + definition.id, + overrides, + PLATFORM, + ); + const displayBindings = recording && draftBinding + ? [draftBinding] + : effectiveBindings; + const labels = displayBindings.map((binding) => + formatKeyboardShortcutBinding(binding, PLATFORM) + ); + const isDefault = shortcutOverrideIsDefault(definition.id, overrides); + const isDisabled = !isDefault && overrides[definition.id] === null; + + return ( +
+
+ {definition.label} + + + +
+ {recording && error && ( +

+ {error} +

+ )} +
+ ); +} + +function KeyboardShortcutEditor() { + const [overrides, setOverrides] = useState({}); + const [recordingAction, setRecordingAction] = + useState(null); + const [draftBinding, setDraftBinding] = + useState(null); + const [recordingError, setRecordingError] = useState(null); + + useEffect(() => { + api.getPref(KEYBOARD_SHORTCUTS_PREF) + .then((value) => { + setOverrides(normalizeKeyboardShortcutOverrides(value)); + }) + .catch(() => { }); + }, []); + + useEffect(() => { + if (!recordingAction) return; + const target = document.querySelector( + `[data-shortcut-action="${recordingAction}"]`, + ); + target?.focus(); + }, [recordingAction]); + + const saveOverrides = useCallback(async (next: KeyboardShortcutOverrides) => { + setOverrides(next); + await api.setPref(KEYBOARD_SHORTCUTS_PREF, next); + }, []); + + const saveOverride = useCallback(async ( + actionId: KeyboardShortcutActionId, + override: KeyboardShortcutOverride | undefined, + ) => { + await saveOverrides(withKeyboardShortcutOverride( + overrides, + actionId, + override, + )); + }, [overrides, saveOverrides]); + + const startRecording = useCallback((actionId: KeyboardShortcutActionId) => { + setRecordingAction(actionId); + setDraftBinding(null); + setRecordingError(null); + }, []); + + const cancelRecording = useCallback(() => { + setRecordingAction(null); + setDraftBinding(null); + setRecordingError(null); + }, []); + + useEffect(() => { + api.setKeyboardShortcutRecording(recordingAction !== null); + return () => api.setKeyboardShortcutRecording(false); + }, [recordingAction]); + + const clearShortcut = useCallback((actionId: KeyboardShortcutActionId) => { + void saveOverride(actionId, null); + if (recordingAction === actionId) cancelRecording(); + }, [cancelRecording, recordingAction, saveOverride]); + + const resetShortcut = useCallback((actionId: KeyboardShortcutActionId) => { + void saveOverride(actionId, undefined); + if (recordingAction === actionId) cancelRecording(); + }, [cancelRecording, recordingAction, saveOverride]); + + useEffect(() => { + if (!recordingAction) return; + const handler = (event: KeyboardEvent) => { + event.preventDefault(); + event.stopPropagation(); + + if (event.key === "Escape") { + cancelRecording(); + return; + } + if (event.key === "Backspace" || event.key === "Delete") { + clearShortcut(recordingAction); + return; + } + + const binding = keyboardShortcutBindingFromInput(event); + if (!binding) return; + setDraftBinding(binding); + + const validationError = validateKeyboardShortcutBinding(binding); + if (validationError) { + setRecordingError(validationError); + return; + } + + const conflicts = findKeyboardShortcutConflicts( + binding, + recordingAction, + overrides, + PLATFORM, + ); + if (conflicts.length > 0) { + setRecordingError(`Already used by ${conflicts[0]!.label}.`); + return; + } + + const nextOverride = shortcutOverrideFromBinding(binding); + const next = shortcutBindingsMatchDefault(recordingAction, nextOverride) + ? withKeyboardShortcutOverride(overrides, recordingAction, undefined) + : withKeyboardShortcutOverride(overrides, recordingAction, nextOverride); + void saveOverrides(next); + cancelRecording(); + }; + window.addEventListener("keydown", handler, { capture: true }); + return () => window.removeEventListener("keydown", handler, { + capture: true, + }); + }, [ + cancelRecording, + clearShortcut, + overrides, + recordingAction, + saveOverrides, + ]); + + return ( +
+ {KEYBOARD_SHORTCUT_CATEGORIES.map((category) => { + const definitions = KEYBOARD_SHORTCUT_DEFINITIONS.filter( + (definition) => definition.category === category, + ); + return ( +
+

+ {category} +

+
+ {definitions.map((definition) => ( + + ))} +
+
+ ); + })} +
+ ); +} + type TerminalMode = "tmux" | "sidecar"; const TERMINAL_MODES: { @@ -492,7 +787,7 @@ function ControlsPane() {

Keyboard Shortcuts

- +

Mouse Controls

diff --git a/collab-electron/src/windows/shell/src/renderer.js b/collab-electron/src/windows/shell/src/renderer.js index f1228473..510e0d04 100644 --- a/collab-electron/src/windows/shell/src/renderer.js +++ b/collab-electron/src/windows/shell/src/renderer.js @@ -6,7 +6,14 @@ import { } from "./canvas-state.js"; import { attachMarquee } from "./tile-interactions.js"; import { initDarkMode, applyCanvasOpacity } from "./dark-mode.js"; -import { createWebview, isFocusSearchShortcut } from "./webview-factory.js"; +import { createWebview } from "./webview-factory.js"; +import { + KEYBOARD_SHORTCUTS_PREF, + findKeyboardShortcutAction, + formatKeyboardShortcutBindings, + getEffectiveKeyboardShortcutBindings, + normalizeKeyboardShortcutOverrides, +} from "@collab/shared/keyboard-shortcuts"; import { createViewport } from "./canvas-viewport.js"; import { createEdgeIndicators } from "./edge-indicators.js"; import { createMinimap } from "./canvas-minimap.js"; @@ -17,7 +24,8 @@ import { createTileManager } from "./tile-manager.js"; import { updateTileTitle, getTileLabel } from "./tile-renderer.js"; const CANVAS_DBLCLICK_SUPPRESS_MS = 500; -const IS_WINDOWS = window.shellApi.getPlatform() === "win32"; +const PLATFORM = window.shellApi.getPlatform(); +const IS_WINDOWS = PLATFORM === "win32"; const viewportState = { panX: 0, panY: 0, zoom: 1 }; @@ -28,6 +36,57 @@ canvasEl.tabIndex = -1; document.documentElement.classList.toggle("platform-win", IS_WINDOWS); document.body.classList.toggle("platform-win", IS_WINDOWS); +let keyboardShortcutOverrides = {}; + +function setKeyboardShortcutOverrides(value) { + keyboardShortcutOverrides = normalizeKeyboardShortcutOverrides(value); + updateShortcutTooltips(); +} + +function getShortcutAction(input) { + return findKeyboardShortcutAction( + input, + keyboardShortcutOverrides, + PLATFORM, + ); +} + +function isShellHandledShortcutAction(action) { + return action !== "zoom-in" && + action !== "zoom-out" && + action !== "zoom-reset" && + action !== "toggle-full-screen"; +} + +function shortcutLabels(action) { + return formatKeyboardShortcutBindings( + getEffectiveKeyboardShortcutBindings( + action, + keyboardShortcutOverrides, + PLATFORM, + ), + PLATFORM, + ); +} + +function setShortcutTooltip(selector, action) { + const el = document.querySelector(selector); + if (!el) return; + const labels = shortcutLabels(action); + if (labels.length === 0) { + el.removeAttribute("data-shortcut"); + } else { + el.dataset.shortcut = labels.join(" / "); + } +} + +function updateShortcutTooltips() { + setShortcutTooltip("#nav-toggle", "sidebar-files"); + setShortcutTooltip("#agent-toggle", "toggle-agent"); + setShortcutTooltip("#settings-btn", "toggle-settings"); + setShortcutTooltip("#new-tile-btn", "new-tile"); +} + // -- Alpha banner dismiss -- document.getElementById("alpha-dismiss").addEventListener("click", (e) => { @@ -48,12 +107,17 @@ window.shellApi.getPref("canvasOpacity").then((v) => { applyCanvasOpacity(lastCanvasOpacity); broadcastCanvasOpacity(); }); +window.shellApi.getPref(KEYBOARD_SHORTCUTS_PREF).then((value) => { + setKeyboardShortcutOverrides(value); +}); window.shellApi.onPrefChanged((key, value) => { if (key === "canvasOpacity") { lastCanvasOpacity = value; applyCanvasOpacity(value); broadcastCanvasOpacity(); + } else if (key === KEYBOARD_SHORTCUTS_PREF) { + setKeyboardShortcutOverrides(value); } }); @@ -193,9 +257,10 @@ async function init() { noteSurfaceFocus("viewer"); }); singletonViewer.setBeforeInput((event, detail) => { - if (!isFocusSearchShortcut(detail)) return; + const action = getShortcutAction(detail); + if (!action || !isShellHandledShortcutAction(action)) return; event.preventDefault(); - handleShortcut("focus-file-search"); + handleShortcut(action); }); const singletonWebviews = { @@ -1130,20 +1195,10 @@ async function init() { window.shellApi.onShortcut(handleShortcut); window.addEventListener("keydown", (event) => { - if (!isFocusSearchShortcut(event)) return; + const action = getShortcutAction(event); + if (!action || !isShellHandledShortcutAction(action)) return; event.preventDefault(); - handleShortcut("focus-file-search"); - }); - - window.addEventListener("keydown", (event) => { - if (!event.metaKey || event.shiftKey || event.altKey) return; - if (event.key === "n") { - event.preventDefault(); - handleShortcut("new-tile"); - } else if (event.key === "w") { - event.preventDefault(); - handleShortcut("close-tile"); - } + handleShortcut(action); }); // -- Browser tile Cmd+L focus URL --