From 8ebbbc94abfbe74e51f941f0de4e98ba56c74933 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Tue, 19 May 2026 18:13:30 -0400 Subject: [PATCH] Add custom window shortcut recorder Co-authored-by: Orca --- src/main/browser/browser-guest-ui.ts | 110 ++++-- src/main/browser/browser-manager.test.ts | 65 ++++ src/main/browser/browser-manager.ts | 11 +- src/main/persistence.ts | 6 + src/main/window/createMainWindow.test.ts | 3 +- src/main/window/createMainWindow.ts | 10 +- .../src/components/settings/ShortcutsPane.tsx | 32 +- .../WindowShortcutBindingsSection.tsx | 212 ++++++++++++ src/shared/constants.ts | 1 + src/shared/types.ts | 4 + src/shared/window-shortcut-bindings.test.ts | 32 ++ src/shared/window-shortcut-bindings.ts | 318 ++++++++++++++++++ src/shared/window-shortcut-policy.test.ts | 44 +++ src/shared/window-shortcut-policy.ts | 54 ++- 14 files changed, 863 insertions(+), 39 deletions(-) create mode 100644 src/renderer/src/components/settings/WindowShortcutBindingsSection.tsx create mode 100644 src/shared/window-shortcut-bindings.test.ts create mode 100644 src/shared/window-shortcut-bindings.ts diff --git a/src/main/browser/browser-guest-ui.ts b/src/main/browser/browser-guest-ui.ts index 2391e635a32..041516c7b1c 100644 --- a/src/main/browser/browser-guest-ui.ts +++ b/src/main/browser/browser-guest-ui.ts @@ -1,3 +1,6 @@ +/* eslint-disable max-lines -- Why: guest input forwarding keeps context-menu, +grab-mode, app-shortcut, and renderer-forwarding behavior beside the single +webContents boundary they all protect. */ import { screen, webContents } from 'electron' import { normalizeBrowserNavigationUrl, @@ -6,12 +9,18 @@ import { } from '../../shared/browser-url' import { isWindowShortcutModifierChord, - resolveWindowShortcutAction + resolveWindowShortcutAction, + type WindowShortcutAction } from '../../shared/window-shortcut-policy' +import { + resolveCustomWindowShortcutAction, + type WindowShortcutBindings +} from '../../shared/window-shortcut-bindings' import { readGuestNavigationState } from './browser-guest-navigation-state' type ResolveRenderer = (browserTabId: string) => Electron.WebContents | null type ShouldForwardDictationShortcut = () => boolean +type GetWindowShortcutBindings = () => WindowShortcutBindings | undefined function isTerminalTabSwitchChord(input: Electron.Input): boolean { return ( @@ -31,6 +40,54 @@ function isControlKeyRelease(input: Electron.Input): boolean { return input.type === 'keyUp' && (input.code === 'ControlLeft' || input.code === 'ControlRight') } +function sendResolvedWindowShortcutAction( + renderer: Electron.WebContents, + action: WindowShortcutAction, + shouldForwardDictationShortcut?: ShouldForwardDictationShortcut +): boolean { + if (action.type === 'zoom') { + renderer.send('terminal:zoom', action.direction) + return true + } + if (action.type === 'toggleLeftSidebar') { + renderer.send('ui:toggleLeftSidebar') + return true + } + if (action.type === 'toggleRightSidebar') { + renderer.send('ui:toggleRightSidebar') + return true + } + if (action.type === 'toggleWorktreePalette') { + renderer.send('ui:toggleWorktreePalette') + return true + } + if (action.type === 'toggleFloatingTerminal') { + renderer.send('ui:toggleFloatingTerminal') + return true + } + if (action.type === 'openQuickOpen') { + renderer.send('ui:openQuickOpen') + return true + } + if (action.type === 'openNewWorkspace') { + renderer.send('ui:openNewWorkspace') + return true + } + if (action.type === 'jumpToWorktreeIndex') { + renderer.send('ui:jumpToWorktreeIndex', action.index) + return true + } + if (action.type === 'worktreeHistoryNavigate') { + renderer.send('ui:worktreeHistoryNavigate', action.direction) + return true + } + if (action.type === 'dictationKeyDown' && shouldForwardDictationShortcut?.()) { + renderer.send('ui:dictationKeyDown') + return true + } + return false +} + export function setupGuestContextMenu(args: { browserTabId: string guest: Electron.WebContents @@ -228,8 +285,15 @@ export function setupGuestShortcutForwarding(args: { guest: Electron.WebContents resolveRenderer: ResolveRenderer shouldForwardDictationShortcut?: ShouldForwardDictationShortcut + getWindowShortcutBindings?: GetWindowShortcutBindings }): () => void { - const { browserTabId, guest, resolveRenderer, shouldForwardDictationShortcut } = args + const { + browserTabId, + guest, + resolveRenderer, + shouldForwardDictationShortcut, + getWindowShortcutBindings + } = args let ctrlTabSwitching = false const handler = (event: Electron.Event, input: Electron.Input): void => { if (isCtrlTabSwitchKey(input)) { @@ -258,30 +322,35 @@ export function setupGuestShortcutForwarding(args: { // Alt and must be handled before the generic modifier-chord gate below, // which rejects Alt. Every other chord handled further down can reuse // the same `action` rather than re-running the full predicate chain. - const action = resolveWindowShortcutAction(input, process.platform) + const windowShortcutBindings = getWindowShortcutBindings?.() + const action = resolveWindowShortcutAction(input, process.platform, windowShortcutBindings) + const customAction = resolveCustomWindowShortcutAction(input, windowShortcutBindings) if (input.isAutoRepeat) { if (action?.type === 'dictationKeyDown' && shouldForwardDictationShortcut?.()) { event.preventDefault() } return } - if (action?.type === 'worktreeHistoryNavigate') { - // Why: preventDefault unconditionally — if we cannot resolve the - // renderer (torn-down tab or teardown race), dropping the keystroke - // into the guest's webContents would let Chromium / the guest page - // handle Cmd+Alt+Arrow as their own chord (e.g. guest-side text - // navigation). Consistency with the main-window path is preserved - // only by suppressing the event here too. + if (customAction) { + if (customAction.type === 'dictationKeyDown' && !shouldForwardDictationShortcut?.()) { + return + } + // Why: custom bindings may include Alt or chords that overlap guest + // browser shortcuts. Resolve the shared policy before the guest-specific + // fallbacks so recorded shortcuts behave the same from focused webviews. event.preventDefault() const renderer = resolveRenderer(browserTabId) - renderer?.send('ui:worktreeHistoryNavigate', action.direction) + if (renderer) { + sendResolvedWindowShortcutAction(renderer, customAction, shouldForwardDictationShortcut) + } return } - - if (action?.type === 'toggleFloatingTerminal') { + if (action?.type === 'worktreeHistoryNavigate' || action?.type === 'toggleFloatingTerminal') { event.preventDefault() const renderer = resolveRenderer(browserTabId) - renderer?.send('ui:toggleFloatingTerminal') + if (renderer) { + sendResolvedWindowShortcutAction(renderer, action, shouldForwardDictationShortcut) + } return } @@ -358,19 +427,10 @@ export function setupGuestShortcutForwarding(args: { renderer.send('ui:closeActiveTab') } else if (input.shift && (input.code === 'BracketRight' || input.code === 'BracketLeft')) { renderer.send('ui:switchTab', input.code === 'BracketRight' ? 1 : -1) - } else if (action?.type === 'toggleWorktreePalette') { - renderer.send('ui:toggleWorktreePalette') - } else if (action?.type === 'openQuickOpen') { - renderer.send('ui:openQuickOpen') - } else if (action?.type === 'openNewWorkspace') { - renderer.send('ui:openNewWorkspace') - } else if (action?.type === 'jumpToWorktreeIndex') { - renderer.send('ui:jumpToWorktreeIndex', action.index) - } else if (action?.type === 'dictationKeyDown') { - if (!shouldForwardDictationShortcut?.()) { + } else if (action) { + if (!sendResolvedWindowShortcutAction(renderer, action, shouldForwardDictationShortcut)) { return } - renderer.send('ui:dictationKeyDown') } else { return } diff --git a/src/main/browser/browser-manager.test.ts b/src/main/browser/browser-manager.test.ts index 1a5ef7ae022..ca627e7d484 100644 --- a/src/main/browser/browser-manager.test.ts +++ b/src/main/browser/browser-manager.test.ts @@ -59,6 +59,7 @@ describe('browserManager', () => { webContentsFromIdMock.mockReset() browserManager.unregisterAll() browserManager.setDictationShortcutForwardingPredicate(null) + browserManager.setWindowShortcutBindingsResolver(null) }) afterEach(() => { @@ -1110,6 +1111,70 @@ describe('browserManager', () => { expect(rendererSendMock).toHaveBeenNthCalledWith(9, 'ui:hardReloadBrowserPage') }) + it('lets custom browser guest shortcuts override guest-specific defaults', () => { + const isDarwin = process.platform === 'darwin' + const rendererSendMock = vi.fn() + const guest = { + id: 409, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock + } + + webContentsFromIdMock.mockImplementation((id: number) => { + if (id === guest.id) { + return guest + } + if (id === rendererWebContentsId) { + return { isDestroyed: vi.fn(() => false), send: rendererSendMock } + } + return null + }) + + browserManager.setWindowShortcutBindingsResolver(() => ({ + openQuickOpen: { + key: 'l', + code: 'KeyL', + meta: isDarwin, + control: !isDarwin + } + })) + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'browser-1', + webContentsId: guest.id, + rendererWebContentsId + }) + + const beforeInputHandler = guestOnMock.mock.calls + .filter(([event]) => event === 'before-input-event') + .at(-1)?.[1] as + | ((event: { preventDefault: () => void }, input: Record) => void) + | undefined + + const preventDefault = vi.fn() + beforeInputHandler?.( + { preventDefault }, + { + type: 'keyDown', + code: 'KeyL', + key: 'l', + meta: isDarwin, + control: !isDarwin, + alt: false, + shift: false + } + ) + + expect(preventDefault).toHaveBeenCalledTimes(1) + expect(rendererSendMock).toHaveBeenCalledTimes(1) + expect(rendererSendMock).toHaveBeenCalledWith('ui:openQuickOpen') + }) + it('forwards browser guest Ctrl+Tab keydown and Ctrl release', () => { const rendererSendMock = vi.fn() const guest = { diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index caab6977686..3e654d67cef 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -37,6 +37,7 @@ import { import { ANTI_DETECTION_SCRIPT } from './anti-detection' import { cleanElectronUserAgent } from './browser-session-ua' import type { BrowserViewportOverride } from '../../shared/types' +import type { WindowShortcutBindings } from '../../shared/window-shortcut-bindings' import { type BrowserAnnotationViewportBridgeOptions, BROWSER_ANNOTATION_VIEWPORT_BRIDGE_WORLD_ID, @@ -119,6 +120,7 @@ export class BrowserManager { private readonly policyAttachedGuestIds = new Set() private readonly policyCleanupByGuestId = new Map void>() private shouldForwardDictationShortcut: (() => boolean) | null = null + private getWindowShortcutBindings: (() => WindowShortcutBindings | undefined) | null = null private readonly pendingLoadFailuresByGuestId = new Map< number, { code: number; description: string; validatedUrl: string } @@ -127,6 +129,12 @@ export class BrowserManager { setDictationShortcutForwardingPredicate(predicate: (() => boolean) | null): void { this.shouldForwardDictationShortcut = predicate } + + setWindowShortcutBindingsResolver( + resolver: (() => WindowShortcutBindings | undefined) | null + ): void { + this.getWindowShortcutBindings = resolver + } private readonly pendingPermissionEventsByGuestId = new Map() private readonly pendingPopupEventsByGuestId = new Map() private readonly pendingDownloadIdsByGuestId = new Map() @@ -1282,7 +1290,8 @@ export class BrowserManager { guest, resolveRenderer: (tabId) => resolveRendererWebContents(this.rendererWebContentsIdByTabId, tabId), - shouldForwardDictationShortcut: () => this.shouldForwardDictationShortcut?.() ?? false + shouldForwardDictationShortcut: () => this.shouldForwardDictationShortcut?.() ?? false, + getWindowShortcutBindings: () => this.getWindowShortcutBindings?.() }) ) } diff --git a/src/main/persistence.ts b/src/main/persistence.ts index f093f51514d..8275916f0e6 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -78,6 +78,7 @@ import { getRepoIdFromWorktreeId, getWorktreePathBasenameFromId } from '../share import { normalizeTerminalQuickCommands } from '../shared/terminal-quick-commands' import { normalizeVisibleTaskProviders } from '../shared/task-providers' import { normalizeOpenInApplications } from '../shared/open-in-applications' +import { normalizeWindowShortcutBindings } from '../shared/window-shortcut-bindings' import { DEFAULT_WORKSPACE_STATUS_ID, clampWorkspaceBoardColumnWidth, @@ -2124,6 +2125,11 @@ export class Store { if ('openInApplications' in updates) { sanitizedUpdates.openInApplications = normalizeOpenInApplications(updates.openInApplications) } + if ('windowShortcutBindings' in updates) { + sanitizedUpdates.windowShortcutBindings = normalizeWindowShortcutBindings( + updates.windowShortcutBindings + ) + } // Why: `telemetry` is deep-merged for the same reason `notifications` is — // partial updates from the Privacy pane / consent flow (e.g., flipping // only `optedIn`) must not clobber sibling fields like `installId` or diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index f13475c3acf..6262c58a3d9 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -47,7 +47,8 @@ vi.mock('../../../resources/icon-dev.png?asset', () => ({ vi.mock('../browser/browser-manager', () => ({ browserManager: { attachGuestPolicies: attachGuestPoliciesMock, - setDictationShortcutForwardingPredicate: vi.fn() + setDictationShortcutForwardingPredicate: vi.fn(), + setWindowShortcutBindingsResolver: vi.fn() } })) diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index dc13e6ea4c2..b44e2a81a82 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -173,6 +173,9 @@ export function createMainWindow( // dictation session whose final text would be dropped. return false }) + browserManager.setWindowShortcutBindingsResolver( + () => store?.getSettings().windowShortcutBindings + ) const blur = settings?.windowBackgroundBlur ?? false // Why: native blur requires platform-specific Electron APIs. macOS uses // vibrancy (needs transparent: true), Windows uses backgroundMaterial. @@ -609,7 +612,11 @@ export function createMainWindow( // Why: keep the main-process interception surface as an explicit allowlist. // Anything outside this helper must continue to the renderer/PTTY so // readline control chords are not silently stolen above the terminal. - const action = resolveWindowShortcutAction(input, process.platform) + const action = resolveWindowShortcutAction( + input, + process.platform, + store?.getSettings().windowShortcutBindings + ) if (!action) { return } @@ -835,6 +842,7 @@ export function createMainWindow( ipcMain.removeListener(minimizeChannel, onMinimize) ipcMain.removeListener(maximizeChannel, onMaximize) browserManager.setDictationShortcutForwardingPredicate(null) + browserManager.setWindowShortcutBindingsResolver(null) ipcMain.removeListener(requestCloseChannel, onRequestClose) ipcMain.removeListener(popupMenuChannel, onPopupMenu) ipcMain.removeHandler(isMaximizedChannel) diff --git a/src/renderer/src/components/settings/ShortcutsPane.tsx b/src/renderer/src/components/settings/ShortcutsPane.tsx index 404ad93f021..487fe31a7a8 100644 --- a/src/renderer/src/components/settings/ShortcutsPane.tsx +++ b/src/renderer/src/components/settings/ShortcutsPane.tsx @@ -6,6 +6,11 @@ import { SearchableSetting } from './SearchableSetting' import { matchesSettingsSearch, type SettingsSearchEntry } from './settings-search' import { Label } from '../ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' +import { + CUSTOMIZABLE_ACTION_TITLES, + WindowShortcutBindingsSection, + WINDOW_SHORTCUT_BINDING_SEARCH_ENTRIES +} from './WindowShortcutBindingsSection' type ShortcutItem = { action: string @@ -251,6 +256,7 @@ const CTRL_TAB_BEHAVIOR_SEARCH_ENTRIES: SettingsSearchEntry[] = [ // Why: search is supposed to stay in lockstep with the rendered shortcuts. Deriving // both from one definition prevents the registry drift regression this branch introduced. export const SHORTCUTS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ + ...WINDOW_SHORTCUT_BINDING_SEARCH_ENTRIES, ...SHORTCUT_GROUP_DEFINITIONS.flatMap((group) => group.items.map((item) => ({ title: item.action, @@ -274,11 +280,13 @@ export function ShortcutsPane(): React.JSX.Element { () => SHORTCUT_GROUP_DEFINITIONS.map((group) => ({ title: group.title, - items: group.items.map((item) => ({ - action: item.action, - keys: item.keys({ mod, shift, enter }) - })) - })), + items: group.items + .filter((item) => !CUSTOMIZABLE_ACTION_TITLES.has(item.action)) + .map((item) => ({ + action: item.action, + keys: item.keys({ mod, shift, enter }) + })) + })).filter((group) => group.items.length > 0), [mod, shift, enter] ) @@ -291,11 +299,13 @@ export function ShortcutsPane(): React.JSX.Element { Object.fromEntries( SHORTCUT_GROUP_DEFINITIONS.map((groupDef) => [ groupDef.title, - groupDef.items.map((defItem) => ({ - title: defItem.action, - description: `${groupDef.title} shortcut`, - keywords: defItem.searchKeywords - })) + groupDef.items + .filter((defItem) => !CUSTOMIZABLE_ACTION_TITLES.has(defItem.action)) + .map((defItem) => ({ + title: defItem.action, + description: `${groupDef.title} shortcut`, + keywords: defItem.searchKeywords + })) ]) ), [] @@ -311,6 +321,8 @@ export function ShortcutsPane(): React.JSX.Element {

+ + {matchesSettingsSearch(searchQuery, CTRL_TAB_BEHAVIOR_SEARCH_ENTRIES) ? ( ({ + title: definition.title, + description: `${definition.group} shortcut`, + keywords: definition.keywords + })) + +export const CUSTOMIZABLE_ACTION_TITLES = new Set( + WINDOW_SHORTCUT_BINDING_DEFINITIONS.map((definition) => definition.title) +) + +type WindowShortcutBindingsSectionProps = { + searchQuery: string +} + +export function WindowShortcutBindingsSection({ + searchQuery +}: WindowShortcutBindingsSectionProps): React.JSX.Element | null { + const windowShortcutBindings = useAppStore( + (state) => state.settings?.windowShortcutBindings ?? {} + ) + const updateSettings = useAppStore((state) => state.updateSettings) + const isMac = navigator.userAgent.includes('Mac') + const shortcutPlatform = (isMac ? 'darwin' : 'linux') as NodeJS.Platform + const [recordingActionId, setRecordingActionId] = useState( + null + ) + const [recordingError, setRecordingError] = useState(null) + + const customizableRows = useMemo( + () => + WINDOW_SHORTCUT_BINDING_DEFINITIONS.map((definition) => { + const binding = effectiveWindowShortcutBinding( + definition.id, + shortcutPlatform, + windowShortcutBindings + ) + return { + ...definition, + binding, + keys: formatWindowShortcutBinding(binding, isMac), + customized: Boolean(windowShortcutBindings[definition.id]) + } + }), + [isMac, shortcutPlatform, windowShortcutBindings] + ) + + const setShortcutBinding = ( + actionId: WindowShortcutBindingActionId, + binding: WindowShortcutBinding + ): void => { + void updateSettings({ + windowShortcutBindings: { + ...windowShortcutBindings, + [actionId]: binding + } + }) + } + + const resetShortcutBinding = (actionId: WindowShortcutBindingActionId): void => { + const next: WindowShortcutBindings = { ...windowShortcutBindings } + delete next[actionId] + void updateSettings({ windowShortcutBindings: next }) + } + + const handleRecorderKeyDown = ( + event: React.KeyboardEvent, + actionId: WindowShortcutBindingActionId + ): void => { + if (recordingActionId !== actionId) { + return + } + event.preventDefault() + event.stopPropagation() + if (event.key === 'Escape' && !event.metaKey && !event.ctrlKey && !event.altKey) { + setRecordingActionId(null) + setRecordingError(null) + return + } + const binding = bindingFromKeyboardEvent(event.nativeEvent) + if (!binding) { + setRecordingError('Press a key with Ctrl, Cmd, or Alt.') + return + } + const conflict = customizableRows.find( + (row) => row.id !== actionId && bindingSignature(row.binding) === bindingSignature(binding) + ) + if (conflict) { + setRecordingError(`Already used by ${conflict.title}.`) + return + } + setShortcutBinding(actionId, binding) + setRecordingActionId(null) + setRecordingError(null) + } + + if (!matchesSettingsSearch(searchQuery, WINDOW_SHORTCUT_BINDING_SEARCH_ENTRIES)) { + return null + } + + return ( +
+

+ Customizable +

+
+ {customizableRows + .filter((row) => + matchesSettingsSearch(searchQuery, [ + { + title: row.title, + description: `${row.group} shortcut`, + keywords: row.keywords + } + ]) + ) + .map((row) => { + const isRecording = recordingActionId === row.id + return ( + +
+ {row.title} + {isRecording && recordingError ? ( +

{recordingError}

+ ) : null} +
+
+ + + +
+
+ ) + })} +
+
+ ) +} + +function bindingFromKeyboardEvent(event: KeyboardEvent): WindowShortcutBinding | null { + if ( + event.key === 'Meta' || + event.key === 'Control' || + event.key === 'Alt' || + event.key === 'Shift' + ) { + return null + } + const binding: WindowShortcutBinding = { + key: event.key, + code: event.code, + meta: event.metaKey, + control: event.ctrlKey, + alt: event.altKey, + shift: event.shiftKey + } + return isRecordableWindowShortcutBinding(binding) ? binding : null +} + +function bindingSignature(binding: WindowShortcutBinding): string { + return [ + binding.meta === true ? 'meta' : '', + binding.control === true ? 'control' : '', + binding.alt === true ? 'alt' : '', + binding.shift === true ? 'shift' : '', + binding.key.toLowerCase(), + binding.code ?? '' + ].join('|') +} diff --git a/src/shared/constants.ts b/src/shared/constants.ts index f7bc13c8a79..035c303c844 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -209,6 +209,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings { showTitlebarAppName: true, showTasksButton: true, ctrlTabOrderMode: 'mru', + windowShortcutBindings: {}, floatingTerminalEnabled: true, floatingTerminalDefaultedForAllUsers: true, floatingTerminalCwd: '~', diff --git a/src/shared/types.ts b/src/shared/types.ts index d82f4794212..855abfdb9f8 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -14,6 +14,7 @@ import type { GitLabProjectSettings } from './gitlab-types' import type { TaskProvider } from './task-providers' import type { FeatureTipId } from './feature-tips' import type { GitBranchChangeStatus } from './git-status-types' +import type { WindowShortcutBindings } from './window-shortcut-bindings' // Re-exported for backward compat with renderer call sites that import // `WorkspaceCreateTelemetrySource` from '../../../shared/types'. @@ -1510,6 +1511,9 @@ export type GlobalSettings = { /** Controls how Ctrl+Tab chooses the next visible tab. Optional for * profiles saved before this setting existed; readers default to MRU. */ ctrlTabOrderMode?: CtrlTabOrderMode + /** User-recorded window shortcut bindings. Defaults continue to work when + * a key is not present, and malformed bindings are dropped on write. */ + windowShortcutBindings?: WindowShortcutBindings /** Why: Floating Terminal is the default global shell surface so users can * reach a terminal outside repo/worktree context immediately. */ floatingTerminalEnabled: boolean diff --git a/src/shared/window-shortcut-bindings.test.ts b/src/shared/window-shortcut-bindings.test.ts new file mode 100644 index 00000000000..c032627b537 --- /dev/null +++ b/src/shared/window-shortcut-bindings.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { + formatWindowShortcutBinding, + isRecordableWindowShortcutBinding, + normalizeWindowShortcutBindings +} from './window-shortcut-bindings' + +describe('window shortcut bindings', () => { + it('normalizes persisted custom bindings and drops unsafe bare keys', () => { + expect( + normalizeWindowShortcutBindings({ + openQuickOpen: { key: 'k', code: 'KeyK', meta: true }, + toggleLeftSidebar: { key: 'b', code: 'KeyB', shift: true }, + unknown: { key: 'x', meta: true } + }) + ).toEqual({ + openQuickOpen: { key: 'k', code: 'KeyK', meta: true } + }) + }) + + it('requires a non-shift modifier for recorded app shortcuts', () => { + expect(isRecordableWindowShortcutBinding({ key: 'k', shift: true })).toBe(false) + expect(isRecordableWindowShortcutBinding({ key: 'k', alt: true })).toBe(true) + }) + + it('formats modifier labels for each platform', () => { + const binding = { key: 'ArrowLeft', code: 'ArrowLeft', meta: true, alt: true, shift: true } + + expect(formatWindowShortcutBinding(binding, true)).toEqual(['⌘', '⌥', '⇧', '←']) + expect(formatWindowShortcutBinding(binding, false)).toEqual(['Meta', 'Alt', 'Shift', '←']) + }) +}) diff --git a/src/shared/window-shortcut-bindings.ts b/src/shared/window-shortcut-bindings.ts new file mode 100644 index 00000000000..f66b0c40472 --- /dev/null +++ b/src/shared/window-shortcut-bindings.ts @@ -0,0 +1,318 @@ +import type { WindowShortcutAction, WindowShortcutInput } from './window-shortcut-policy' + +export type WindowShortcutBindingActionId = + | 'openQuickOpen' + | 'toggleWorktreePalette' + | 'openNewWorkspace' + | 'toggleLeftSidebar' + | 'toggleRightSidebar' + | 'toggleFloatingTerminal' + | 'zoomIn' + | 'zoomOut' + | 'zoomReset' + | 'worktreeHistoryBack' + | 'worktreeHistoryForward' + | 'dictationKeyDown' + +export type WindowShortcutBinding = { + key: string + code?: string + meta?: boolean + control?: boolean + alt?: boolean + shift?: boolean +} + +export type WindowShortcutBindings = Partial< + Record +> + +export type WindowShortcutBindingDefinition = { + id: WindowShortcutBindingActionId + title: string + group: 'Global' | 'Navigation' | 'View' + keywords: string[] + toAction: () => WindowShortcutAction + defaultBinding: (platform: NodeJS.Platform) => WindowShortcutBinding +} + +export const WINDOW_SHORTCUT_BINDING_DEFINITIONS: WindowShortcutBindingDefinition[] = [ + { + id: 'openQuickOpen', + title: 'Go to File', + group: 'Global', + keywords: ['shortcut', 'global', 'file'], + toAction: () => ({ type: 'openQuickOpen' }), + defaultBinding: (platform) => primaryBinding(platform, 'p', 'KeyP') + }, + { + id: 'toggleWorktreePalette', + title: 'Switch worktree', + group: 'Global', + keywords: ['shortcut', 'global', 'worktree', 'switch', 'jump'], + toAction: () => ({ type: 'toggleWorktreePalette' }), + defaultBinding: (platform) => ({ + ...primaryBinding(platform, 'j', 'KeyJ'), + shift: platform !== 'darwin' + }) + }, + { + id: 'openNewWorkspace', + title: 'Create worktree', + group: 'Global', + keywords: ['shortcut', 'global', 'worktree'], + toAction: () => ({ type: 'openNewWorkspace' }), + defaultBinding: (platform) => primaryBinding(platform, 'n', 'KeyN') + }, + { + id: 'toggleLeftSidebar', + title: 'Toggle Sidebar', + group: 'Global', + keywords: ['shortcut', 'sidebar'], + toAction: () => ({ type: 'toggleLeftSidebar' }), + defaultBinding: (platform) => primaryBinding(platform, 'b', 'KeyB') + }, + { + id: 'toggleRightSidebar', + title: 'Toggle Right Sidebar', + group: 'Global', + keywords: ['shortcut', 'sidebar', 'right'], + toAction: () => ({ type: 'toggleRightSidebar' }), + defaultBinding: (platform) => primaryBinding(platform, 'l', 'KeyL') + }, + { + id: 'toggleFloatingTerminal', + title: 'Toggle Floating Terminal', + group: 'Global', + keywords: ['shortcut', 'terminal', 'floating'], + toAction: () => ({ type: 'toggleFloatingTerminal' }), + defaultBinding: (platform) => ({ ...primaryBinding(platform, 't', 'KeyT'), alt: true }) + }, + { + id: 'dictationKeyDown', + title: 'Dictation', + group: 'Global', + keywords: ['shortcut', 'dictation', 'voice', 'speech', 'microphone'], + toAction: () => ({ type: 'dictationKeyDown' }), + defaultBinding: (platform) => primaryBinding(platform, 'e', 'KeyE') + }, + { + id: 'worktreeHistoryBack', + title: 'Previous worktree', + group: 'Navigation', + keywords: ['shortcut', 'global', 'worktree', 'history', 'back'], + toAction: () => ({ type: 'worktreeHistoryNavigate', direction: 'back' }), + defaultBinding: (platform) => ({ + ...primaryBinding(platform, 'ArrowLeft', 'ArrowLeft'), + alt: true + }) + }, + { + id: 'worktreeHistoryForward', + title: 'Next worktree', + group: 'Navigation', + keywords: ['shortcut', 'global', 'worktree', 'history', 'forward'], + toAction: () => ({ type: 'worktreeHistoryNavigate', direction: 'forward' }), + defaultBinding: (platform) => ({ + ...primaryBinding(platform, 'ArrowRight', 'ArrowRight'), + alt: true + }) + }, + { + id: 'zoomIn', + title: 'Zoom In', + group: 'View', + keywords: ['shortcut', 'zoom', 'in', 'scale'], + toAction: () => ({ type: 'zoom', direction: 'in' }), + defaultBinding: (platform) => + platform === 'darwin' + ? { key: '=', code: 'Equal', meta: true } + : { key: '+', code: 'Equal', control: true, shift: true } + }, + { + id: 'zoomOut', + title: 'Zoom Out', + group: 'View', + keywords: ['shortcut', 'zoom', 'out', 'scale'], + toAction: () => ({ type: 'zoom', direction: 'out' }), + defaultBinding: (platform) => primaryBinding(platform, '-', 'Minus') + }, + { + id: 'zoomReset', + title: 'Reset Size', + group: 'View', + keywords: ['shortcut', 'zoom', 'reset', 'size', 'actual'], + toAction: () => ({ type: 'zoom', direction: 'reset' }), + defaultBinding: (platform) => primaryBinding(platform, '0', 'Digit0') + } +] + +function primaryBinding( + platform: NodeJS.Platform, + key: string, + code: string +): WindowShortcutBinding { + return platform === 'darwin' ? { key, code, meta: true } : { key, code, control: true } +} + +function modifierValue(value: boolean | undefined): boolean { + return value === true +} + +export function isRecordableWindowShortcutBinding(binding: WindowShortcutBinding): boolean { + // Why: bare keys and Shift-only chords would steal normal typing from + // terminals, editors, and SSH sessions. Custom app shortcuts still need at + // least one non-Shift modifier. + return modifierValue(binding.meta) || modifierValue(binding.control) || modifierValue(binding.alt) +} + +export function normalizeWindowShortcutBindings(input: unknown): WindowShortcutBindings { + if (!input || typeof input !== 'object') { + return {} + } + const validIds = new Set(WINDOW_SHORTCUT_BINDING_DEFINITIONS.map((definition) => definition.id)) + const result: WindowShortcutBindings = {} + for (const [id, value] of Object.entries(input)) { + if (!validIds.has(id as WindowShortcutBindingActionId) || !value || typeof value !== 'object') { + continue + } + const binding = value as Partial + if (typeof binding.key !== 'string' || binding.key.length === 0) { + continue + } + const normalized: WindowShortcutBinding = { + key: binding.key, + ...(typeof binding.code === 'string' && binding.code.length > 0 + ? { code: binding.code } + : {}), + ...(binding.meta === true ? { meta: true } : {}), + ...(binding.control === true ? { control: true } : {}), + ...(binding.alt === true ? { alt: true } : {}), + ...(binding.shift === true ? { shift: true } : {}) + } + if (isRecordableWindowShortcutBinding(normalized)) { + result[id as WindowShortcutBindingActionId] = normalized + } + } + return result +} + +export function effectiveWindowShortcutBinding( + actionId: WindowShortcutBindingActionId, + platform: NodeJS.Platform, + bindings?: WindowShortcutBindings +): WindowShortcutBinding { + const custom = bindings?.[actionId] + if (custom && isRecordableWindowShortcutBinding(custom)) { + return custom + } + const definition = WINDOW_SHORTCUT_BINDING_DEFINITIONS.find((item) => item.id === actionId) + if (!definition) { + throw new Error(`Unknown window shortcut action: ${actionId}`) + } + return definition.defaultBinding(platform) +} + +export function resolveCustomWindowShortcutAction( + input: WindowShortcutInput, + bindings?: WindowShortcutBindings +): WindowShortcutAction | null { + const normalizedBindings = normalizeWindowShortcutBindings(bindings) + for (const definition of WINDOW_SHORTCUT_BINDING_DEFINITIONS) { + const custom = normalizedBindings[definition.id] + if (custom && matchesWindowShortcutBinding(input, custom)) { + return definition.toAction() + } + } + return null +} + +export function matchesWindowShortcutBinding( + input: WindowShortcutInput, + binding: WindowShortcutBinding +): boolean { + if ( + modifierValue(input.meta) !== modifierValue(binding.meta) || + modifierValue(input.control) !== modifierValue(binding.control) || + modifierValue(input.alt) !== modifierValue(binding.alt) || + modifierValue(input.shift) !== modifierValue(binding.shift) + ) { + return false + } + + const bindingKey = normalizeShortcutKey(binding.key) + const inputKey = normalizeShortcutKey(input.key ?? '') + if (bindingKey && inputKey) { + if (isLetter(bindingKey) && isLetter(inputKey)) { + return bindingKey === inputKey + } + if (bindingKey === inputKey) { + return true + } + } + + // Why: `key` is layout-aware and should win for printable characters, but + // Electron can emit empty/dead keys for some layouts. `code` keeps custom + // bindings reachable as a fallback without making QWERTY position primary. + return Boolean(binding.code && input.code && binding.code === input.code) +} + +function isLetter(value: string): boolean { + return value.length === 1 && value >= 'a' && value <= 'z' +} + +function normalizeShortcutKey(key: string): string { + if (key.length === 1) { + return key.toLowerCase() + } + return key +} + +export function formatWindowShortcutBinding( + binding: WindowShortcutBinding, + isMac: boolean +): string[] { + const keys: string[] = [] + if (binding.control) { + keys.push(isMac ? '⌃' : 'Ctrl') + } + if (binding.meta) { + keys.push(isMac ? '⌘' : 'Meta') + } + if (binding.alt) { + keys.push(isMac ? '⌥' : 'Alt') + } + if (binding.shift) { + keys.push(isMac ? '⇧' : 'Shift') + } + keys.push(formatKeyLabel(binding.key, binding.code)) + return keys +} + +function formatKeyLabel(key: string, code?: string): string { + if (key === ' ') { + return 'Space' + } + if (key === 'ArrowUp') { + return '↑' + } + if (key === 'ArrowDown') { + return '↓' + } + if (key === 'ArrowLeft') { + return '←' + } + if (key === 'ArrowRight') { + return '→' + } + if (key === 'Escape') { + return 'Esc' + } + if (key.length === 1) { + return key.toUpperCase() + } + if (key === 'Control' || key === 'Meta' || key === 'Alt' || key === 'Shift') { + return code ?? key + } + return key +} diff --git a/src/shared/window-shortcut-policy.test.ts b/src/shared/window-shortcut-policy.test.ts index 177262eace1..891ee6a5e00 100644 --- a/src/shared/window-shortcut-policy.test.ts +++ b/src/shared/window-shortcut-policy.test.ts @@ -64,6 +64,50 @@ describe('resolveWindowShortcutAction', () => { ).toEqual({ type: 'jumpToWorktreeIndex', index: 2 }) }) + it('resolves user-recorded custom window shortcut bindings before defaults', () => { + expect( + resolveWindowShortcutAction( + { code: 'KeyK', key: 'k', meta: true, control: false, alt: true, shift: false }, + 'darwin', + { + openQuickOpen: { code: 'KeyK', key: 'k', meta: true, alt: true } + } + ) + ).toEqual({ type: 'openQuickOpen' }) + }) + + it('keeps default shortcuts available when custom bindings are absent', () => { + expect( + resolveWindowShortcutAction( + { code: 'KeyP', key: 'p', meta: true, control: false, alt: false, shift: false }, + 'darwin', + {} + ) + ).toEqual({ type: 'openQuickOpen' }) + }) + + it('replaces a default shortcut when that action has a user-recorded binding', () => { + expect( + resolveWindowShortcutAction( + { code: 'KeyP', key: 'p', meta: true, control: false, alt: false, shift: false }, + 'darwin', + { + openQuickOpen: { code: 'KeyK', key: 'k', meta: true, alt: true } + } + ) + ).toBeNull() + + expect( + resolveWindowShortcutAction( + { code: 'KeyK', key: 'k', meta: true, control: false, alt: true, shift: false }, + 'darwin', + { + openQuickOpen: { code: 'KeyK', key: 'k', meta: true, alt: true } + } + ) + ).toEqual({ type: 'openQuickOpen' }) + }) + it('requires shift for the non-mac worktree palette shortcut', () => { expect( resolveWindowShortcutAction( diff --git a/src/shared/window-shortcut-policy.ts b/src/shared/window-shortcut-policy.ts index 4633a9428e0..04c9a06c769 100644 --- a/src/shared/window-shortcut-policy.ts +++ b/src/shared/window-shortcut-policy.ts @@ -1,3 +1,10 @@ +import { + normalizeWindowShortcutBindings, + resolveCustomWindowShortcutAction, + type WindowShortcutBindingActionId, + type WindowShortcutBindings +} from './window-shortcut-bindings' + export type WindowShortcutInput = { type?: string key?: string @@ -125,12 +132,27 @@ function matchesLetterShortcut( export function resolveWindowShortcutAction( input: WindowShortcutInput, - platform: NodeJS.Platform + platform: NodeJS.Platform, + customBindings?: WindowShortcutBindings ): WindowShortcutAction | null { + const normalizedCustomBindings = normalizeWindowShortcutBindings(customBindings) + const isCustomized = (actionId: WindowShortcutBindingActionId): boolean => + Boolean(normalizedCustomBindings[actionId]) + + const customAction = resolveCustomWindowShortcutAction(input, normalizedCustomBindings) + if (customAction) { + return customAction + } + // Why: evaluate the history-navigate chord BEFORE the standard modifier-chord // gate because that gate rejects Alt. The predicate already narrows to // ArrowLeft/ArrowRight so only those two codes reach here. if (isHistoryNavigateChord(input, platform)) { + if ( + isCustomized(input.code === 'ArrowLeft' ? 'worktreeHistoryBack' : 'worktreeHistoryForward') + ) { + return null + } return { type: 'worktreeHistoryNavigate', direction: input.code === 'ArrowLeft' ? 'back' : 'forward' @@ -138,6 +160,9 @@ export function resolveWindowShortcutAction( } if (isFloatingTerminalChord(input, platform)) { + if (isCustomized('toggleFloatingTerminal')) { + return null + } return { type: 'toggleFloatingTerminal' } } @@ -146,14 +171,23 @@ export function resolveWindowShortcutAction( } if (isZoomInShortcut(input)) { + if (isCustomized('zoomIn')) { + return null + } return { type: 'zoom', direction: 'in' } } if (isZoomOutShortcut(input)) { + if (isCustomized('zoomOut')) { + return null + } return { type: 'zoom', direction: 'out' } } if (input.key === '0' && !input.shift) { + if (isCustomized('zoomReset')) { + return null + } return { type: 'zoom', direction: 'reset' } } @@ -161,6 +195,9 @@ export function resolveWindowShortcutAction( matchesLetterShortcut(input, 'j', 'KeyJ') && ((platform === 'darwin' && !input.shift) || (platform !== 'darwin' && input.shift)) ) { + if (isCustomized('toggleWorktreePalette')) { + return null + } return { type: 'toggleWorktreePalette' } } @@ -169,14 +206,23 @@ export function resolveWindowShortcutAction( // the renderer's window-capture handler can preventDefault, causing ^B / ^L // to appear in the terminal alongside the sidebar toggle. if (matchesLetterShortcut(input, 'b', 'KeyB') && !input.shift) { + if (isCustomized('toggleLeftSidebar')) { + return null + } return { type: 'toggleLeftSidebar' } } if (matchesLetterShortcut(input, 'l', 'KeyL') && !input.shift) { + if (isCustomized('toggleRightSidebar')) { + return null + } return { type: 'toggleRightSidebar' } } if (matchesLetterShortcut(input, 'p', 'KeyP') && !input.shift) { + if (isCustomized('openQuickOpen')) { + return null + } return { type: 'openQuickOpen' } } @@ -188,6 +234,9 @@ export function resolveWindowShortcutAction( // the unified composer now exposes source switching inside the name field. if (matchesLetterShortcut(input, 'n', 'KeyN')) { if (!input.alt) { + if (isCustomized('openNewWorkspace')) { + return null + } return { type: 'openNewWorkspace' } } } @@ -201,6 +250,9 @@ export function resolveWindowShortcutAction( // dictation controller is responsible for forwarding the chord through to // the PTY when dictation is intentionally disabled or the user is mid-input. if (matchesLetterShortcut(input, 'e', 'KeyE') && !input.shift) { + if (isCustomized('dictationKeyDown')) { + return null + } return { type: 'dictationKeyDown' } }