Add custom window shortcut recorder

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-05-19 18:13:30 -04:00
co-authored by Orca
parent 28e8981d10
commit 8ebbbc94ab
14 changed files with 863 additions and 39 deletions
+85 -25
View File
@@ -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
}
+65
View File
@@ -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<string, unknown>) => 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 = {
+10 -1
View File
@@ -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<number>()
private readonly policyCleanupByGuestId = new Map<number, () => 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<number, PendingPermissionEvent[]>()
private readonly pendingPopupEventsByGuestId = new Map<number, PendingPopupEvent[]>()
private readonly pendingDownloadIdsByGuestId = new Map<number, string[]>()
@@ -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?.()
})
)
}
+6
View File
@@ -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
+2 -1
View File
@@ -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()
}
}))
+9 -1
View File
@@ -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)
@@ -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 {
</p>
</div>
<WindowShortcutBindingsSection searchQuery={searchQuery} />
{matchesSettingsSearch(searchQuery, CTRL_TAB_BEHAVIOR_SEARCH_ENTRIES) ? (
<SearchableSetting
title="Ctrl+Tab Order"
@@ -0,0 +1,212 @@
import React, { useMemo, useState } from 'react'
import { RotateCcw } from 'lucide-react'
import {
effectiveWindowShortcutBinding,
formatWindowShortcutBinding,
isRecordableWindowShortcutBinding,
WINDOW_SHORTCUT_BINDING_DEFINITIONS,
type WindowShortcutBinding,
type WindowShortcutBindingActionId,
type WindowShortcutBindings
} from '../../../../shared/window-shortcut-bindings'
import { useAppStore } from '../../store'
import { ShortcutKeyCombo } from '../ShortcutKeyCombo'
import { Button } from '../ui/button'
import { matchesSettingsSearch, type SettingsSearchEntry } from './settings-search'
import { SearchableSetting } from './SearchableSetting'
export const WINDOW_SHORTCUT_BINDING_SEARCH_ENTRIES: SettingsSearchEntry[] =
WINDOW_SHORTCUT_BINDING_DEFINITIONS.map((definition) => ({
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<WindowShortcutBindingActionId | null>(
null
)
const [recordingError, setRecordingError] = useState<string | null>(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<HTMLButtonElement>,
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 (
<div className="space-y-3">
<h3 className="border-b border-border/50 pb-2 text-sm font-medium text-muted-foreground">
Customizable
</h3>
<div className="grid gap-2">
{customizableRows
.filter((row) =>
matchesSettingsSearch(searchQuery, [
{
title: row.title,
description: `${row.group} shortcut`,
keywords: row.keywords
}
])
)
.map((row) => {
const isRecording = recordingActionId === row.id
return (
<SearchableSetting
key={row.id}
title={row.title}
description={`${row.group} shortcut`}
keywords={row.keywords}
className="flex items-center justify-between gap-4 py-1"
>
<div className="min-w-0 space-y-0.5">
<span className="text-sm text-foreground">{row.title}</span>
{isRecording && recordingError ? (
<p className="text-xs text-destructive">{recordingError}</p>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">
<ShortcutKeyCombo keys={row.keys} />
<Button
type="button"
variant={isRecording ? 'secondary' : 'outline'}
size="xs"
onClick={() => {
setRecordingActionId(isRecording ? null : row.id)
setRecordingError(null)
}}
onKeyDown={(event) => handleRecorderKeyDown(event, row.id)}
>
{isRecording ? 'Recording' : 'Record'}
</Button>
<Button
type="button"
variant="ghost"
size="icon-xs"
disabled={!row.customized}
onClick={() => resetShortcutBinding(row.id)}
aria-label={`Reset ${row.title} shortcut`}
>
<RotateCcw />
</Button>
</div>
</SearchableSetting>
)
})}
</div>
</div>
)
}
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('|')
}
+1
View File
@@ -209,6 +209,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
showTitlebarAppName: true,
showTasksButton: true,
ctrlTabOrderMode: 'mru',
windowShortcutBindings: {},
floatingTerminalEnabled: true,
floatingTerminalDefaultedForAllUsers: true,
floatingTerminalCwd: '~',
+4
View File
@@ -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
@@ -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', '←'])
})
})
+318
View File
@@ -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<WindowShortcutBindingActionId, WindowShortcutBinding>
>
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<WindowShortcutBinding>
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
}
+44
View File
@@ -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(
+53 -1
View File
@@ -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' }
}