mirror of
https://github.com/stablyai/orca.git
synced 2026-10-13 16:02:49 +00:00
Add custom window shortcut recorder
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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?.()
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}))
|
||||
|
||||
|
||||
@@ -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('|')
|
||||
}
|
||||
@@ -209,6 +209,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
|
||||
showTitlebarAppName: true,
|
||||
showTasksButton: true,
|
||||
ctrlTabOrderMode: 'mru',
|
||||
windowShortcutBindings: {},
|
||||
floatingTerminalEnabled: true,
|
||||
floatingTerminalDefaultedForAllUsers: true,
|
||||
floatingTerminalCwd: '~',
|
||||
|
||||
@@ -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', '←'])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user