feat(tab-bar): add shortcut to open commands for active tab group (#6325)
* Add keyboard shortcut to toggle the Quick Commands menu - New `tab.openQuickCommandsMenu` keybinding action (no default binding) - TabBarQuickCommandsMenu listens for the binding and toggles open/closed - Scoped to the active tab group naturally since the component only mounts when its group is focused * Show keyboard shortcut in Quick Commands menu trigger tooltip * Add tests * expand tests * Expand keyboard toggle to call handleOpenChange and skip repeated keys - Replace `setMenuOpen` toggle with `handleOpenChange(!menuOpen)` so closing via keyboard runs the same reset logic (query, focus frame, value override) - Guard against key-repeat events to prevent rapid toggling on held key - Wrap `handleOpenChange` in `useCallback` so it's stable enough to include in the `useEffect` dependency array without causing spurious re-registrations - Update tests to reflect that re-running the effect between presses is required for the close path, and add a repeat-event test * Add docstring to withShortcutHint func * review: harden quick commands menu shortcut Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com> Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
2b04c81c87
commit
cbd06a7671
|
|
@ -530,6 +530,29 @@ describe('setupGuestShortcutForwarding', () => {
|
|||
expect(rendererSendMock).toHaveBeenNthCalledWith(2, 'ui:browserHistoryNavigate', 'forward')
|
||||
})
|
||||
|
||||
it('forwards quick-command menu shortcuts from focused guest pages', () => {
|
||||
setupGuestShortcutForwarding({
|
||||
browserTabId,
|
||||
guest: makeGuest(),
|
||||
resolveRenderer: () => makeRenderer(),
|
||||
getKeybindings: () => ({
|
||||
'tab.openQuickCommandsMenu': ['Mod+Shift+Q']
|
||||
})
|
||||
})
|
||||
|
||||
const isMac = process.platform === 'darwin'
|
||||
const preventDefault = triggerBeforeInput({
|
||||
code: 'KeyQ',
|
||||
key: 'q',
|
||||
meta: isMac,
|
||||
control: !isMac,
|
||||
shift: true
|
||||
})
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(rendererSendMock).toHaveBeenCalledWith('ui:toggleQuickCommandsMenu')
|
||||
})
|
||||
|
||||
it('consumes guest zoom shortcuts even when the renderer is unavailable', () => {
|
||||
setupGuestShortcutForwarding({
|
||||
browserTabId,
|
||||
|
|
|
|||
|
|
@ -426,6 +426,8 @@ export function setupGuestShortcutForwarding(args: {
|
|||
renderer.send('ui:toggleWorktreePalette')
|
||||
} else if (action?.type === 'openQuickOpen') {
|
||||
renderer.send('ui:openQuickOpen')
|
||||
} else if (action?.type === 'toggleQuickCommandsMenu') {
|
||||
renderer.send('ui:toggleQuickCommandsMenu')
|
||||
} else if (action?.type === 'openNewWorkspace') {
|
||||
renderer.send('ui:openNewWorkspace')
|
||||
} else if (action?.type === 'openWorkspaceBoard') {
|
||||
|
|
|
|||
|
|
@ -167,8 +167,7 @@ describe('ClaudeAgentTeamsService', () => {
|
|||
|
||||
await request(['set-option', '-p', '-t', '%2', 'remain-on-exit', 'failed'])
|
||||
|
||||
const teammateCommand =
|
||||
'cd /repo && env CLAUDECODE=1 claude --agent-id a --teammate-mode auto'
|
||||
const teammateCommand = 'cd /repo && env CLAUDECODE=1 claude --agent-id a --teammate-mode auto'
|
||||
await expect(
|
||||
request(['respawn-pane', '-k', '-t', '%2', '--', teammateCommand])
|
||||
).resolves.toMatchObject({ stdout: '', exitCode: 0 })
|
||||
|
|
@ -224,10 +223,19 @@ describe('ClaudeAgentTeamsService', () => {
|
|||
|
||||
await expect(
|
||||
service.handleTmuxCompat(
|
||||
{ teamId, token, envPane: leaderPane, argv: ['respawn-pane', '-k', '-t', leaderPane, '--', 'cat'] },
|
||||
{
|
||||
teamId,
|
||||
token,
|
||||
envPane: leaderPane,
|
||||
argv: ['respawn-pane', '-k', '-t', leaderPane, '--', 'cat']
|
||||
},
|
||||
api
|
||||
)
|
||||
).resolves.toMatchObject({ ok: false, exitCode: 1, stderr: 'tmux: refusing to respawn leader pane\n' })
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
exitCode: 1,
|
||||
stderr: 'tmux: refusing to respawn leader pane\n'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects stale or unauthorized shim calls', async () => {
|
||||
|
|
|
|||
|
|
@ -899,6 +899,70 @@ describe('createMainWindow', () => {
|
|||
expect(webContents.send).toHaveBeenNthCalledWith(2, 'ui:toggleWorktreePalette')
|
||||
})
|
||||
|
||||
it('suppresses auto-repeat quick-command menu toggles from before-input-event', () => {
|
||||
const windowHandlers: Record<string, (...args: any[]) => void> = {}
|
||||
const webContents = {
|
||||
on: vi.fn((event, handler) => {
|
||||
windowHandlers[event] = handler
|
||||
}),
|
||||
setZoomLevel: vi.fn(),
|
||||
setBackgroundThrottling: vi.fn(),
|
||||
invalidate: vi.fn(),
|
||||
setWindowOpenHandler: vi.fn(),
|
||||
send: vi.fn(),
|
||||
isDevToolsOpened: vi.fn(),
|
||||
openDevTools: vi.fn(),
|
||||
closeDevTools: vi.fn()
|
||||
}
|
||||
const browserWindowInstance = {
|
||||
webContents,
|
||||
on: vi.fn(),
|
||||
isDestroyed: vi.fn(() => false),
|
||||
isMaximized: vi.fn(() => true),
|
||||
isFullScreen: vi.fn(() => false),
|
||||
getSize: vi.fn(() => [1200, 800]),
|
||||
setSize: vi.fn(),
|
||||
maximize: vi.fn(),
|
||||
show: vi.fn(),
|
||||
loadFile: vi.fn(),
|
||||
loadURL: vi.fn()
|
||||
}
|
||||
browserWindowMock.mockImplementation(function () {
|
||||
return browserWindowInstance
|
||||
})
|
||||
|
||||
createMainWindow(null, {
|
||||
getKeybindings: () => ({
|
||||
'tab.openQuickCommandsMenu': ['Mod+Shift+Q']
|
||||
})
|
||||
})
|
||||
|
||||
const isDarwin = process.platform === 'darwin'
|
||||
const input = {
|
||||
type: 'keyDown',
|
||||
code: 'KeyQ',
|
||||
key: 'q',
|
||||
meta: isDarwin,
|
||||
control: !isDarwin,
|
||||
alt: false,
|
||||
shift: true
|
||||
}
|
||||
const firstPreventDefault = vi.fn()
|
||||
windowHandlers['before-input-event']({ preventDefault: firstPreventDefault } as never, input)
|
||||
expect(firstPreventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(webContents.send).toHaveBeenCalledWith('ui:toggleQuickCommandsMenu')
|
||||
|
||||
webContents.send.mockClear()
|
||||
const repeatPreventDefault = vi.fn()
|
||||
windowHandlers['before-input-event']({ preventDefault: repeatPreventDefault } as never, {
|
||||
...input,
|
||||
isAutoRepeat: true
|
||||
})
|
||||
|
||||
expect(repeatPreventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(webContents.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lets Terminal-first pass risky app shortcuts through when terminal input is focused', () => {
|
||||
const windowHandlers: Record<string, (...args: any[]) => void> = {}
|
||||
const webContents = {
|
||||
|
|
|
|||
|
|
@ -788,6 +788,9 @@ export function createMainWindow(
|
|||
case 'openQuickOpen':
|
||||
mainWindow.webContents.send('ui:openQuickOpen')
|
||||
return
|
||||
case 'toggleQuickCommandsMenu':
|
||||
mainWindow.webContents.send('ui:toggleQuickCommandsMenu')
|
||||
return
|
||||
case 'openNewWorkspace':
|
||||
mainWindow.webContents.send('ui:openNewWorkspace')
|
||||
return
|
||||
|
|
@ -862,6 +865,11 @@ export function createMainWindow(
|
|||
return true
|
||||
}
|
||||
|
||||
if (action.type === 'toggleQuickCommandsMenu' && isAutoRepeat) {
|
||||
event.preventDefault()
|
||||
return true
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
if (capturedTerminalActionId) {
|
||||
mainWindow.webContents.send('ui:terminalShortcutCaptured', {
|
||||
|
|
|
|||
|
|
@ -2515,6 +2515,7 @@ export type PreloadApi = {
|
|||
callback: (data: { actionId: KeybindingActionId }) => void
|
||||
) => () => void
|
||||
onOpenQuickOpen: (callback: () => void) => () => void
|
||||
onToggleQuickCommandsMenu: (callback: () => void) => () => void
|
||||
onOpenNewWorkspace: (callback: () => void) => () => void
|
||||
onDeleteCurrentWorkspace: (callback: () => void) => () => void
|
||||
onOpenWorkspaceBoard: (callback: () => void) => () => void
|
||||
|
|
|
|||
|
|
@ -2951,6 +2951,11 @@ const api = {
|
|||
ipcRenderer.on('ui:openQuickOpen', listener)
|
||||
return () => ipcRenderer.removeListener('ui:openQuickOpen', listener)
|
||||
},
|
||||
onToggleQuickCommandsMenu: (callback: () => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent) => callback()
|
||||
ipcRenderer.on('ui:toggleQuickCommandsMenu', listener)
|
||||
return () => ipcRenderer.removeListener('ui:toggleQuickCommandsMenu', listener)
|
||||
},
|
||||
onOpenNewWorkspace: (callback: () => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent) => callback()
|
||||
ipcRenderer.on('ui:openNewWorkspace', listener)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
import { Pencil, Play, Trash2 } from 'lucide-react'
|
||||
import { CommandItem } from '@/components/ui/command'
|
||||
import { isTerminalAgentQuickCommand } from '../../../../shared/terminal-quick-commands'
|
||||
import type { TerminalQuickCommand } from '../../../../shared/types'
|
||||
import { AgentIcon, getAgentLabel } from '@/lib/agent-catalog'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
type TabBarQuickCommandItemProps = {
|
||||
command: TerminalQuickCommand
|
||||
onRun: () => void
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
export function TabBarQuickCommandItem({
|
||||
command,
|
||||
onRun,
|
||||
onEdit,
|
||||
onDelete
|
||||
}: TabBarQuickCommandItemProps): React.JSX.Element {
|
||||
return (
|
||||
<CommandItem
|
||||
value={command.id}
|
||||
onSelect={onRun}
|
||||
className="group/qc mx-1 my-0.5 items-center gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground"
|
||||
>
|
||||
{isTerminalAgentQuickCommand(command) ? (
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
<AgentIcon agent={command.agent} size={12} />
|
||||
</span>
|
||||
) : (
|
||||
<Play
|
||||
className="size-3 shrink-0 text-muted-foreground"
|
||||
fill="currentColor"
|
||||
strokeWidth={0}
|
||||
/>
|
||||
)}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium text-foreground">{command.label}</span>
|
||||
<span className="block truncate font-mono text-[11px] text-muted-foreground">
|
||||
{isTerminalAgentQuickCommand(command)
|
||||
? `${getAgentLabel(command.agent)}: ${command.prompt}`
|
||||
: command.command}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-0.5 can-hover:opacity-0 transition-opacity group-hover/qc:opacity-100 group-data-[selected=true]/qc:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onEdit()
|
||||
}}
|
||||
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
aria-label={translate(
|
||||
'auto.components.tab.bar.TabBarQuickCommandsButton.15529ede69',
|
||||
'Edit {{value0}}',
|
||||
{ value0: command.label }
|
||||
)}
|
||||
>
|
||||
<Pencil className="size-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onDelete()
|
||||
}}
|
||||
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-destructive"
|
||||
aria-label={translate(
|
||||
'auto.components.tab.bar.TabBarQuickCommandsButton.196593b6a9',
|
||||
'Remove {{value0}}',
|
||||
{ value0: command.label }
|
||||
)}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
</CommandItem>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,385 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Capture window keydown listeners so tests can fire them directly.
|
||||
const windowListeners = vi.hoisted(() => new Map<string, (e: KeyboardEvent) => void>())
|
||||
|
||||
const keybindingsMock = vi.hoisted(() => ({
|
||||
matchAction: vi.fn().mockReturnValue(false)
|
||||
}))
|
||||
|
||||
const appStoreMock = vi.hoisted(() => ({
|
||||
state: {
|
||||
activeView: 'terminal' as 'terminal' | 'settings',
|
||||
keybindings: {} as Record<string, string[]>,
|
||||
settings: {
|
||||
terminalShortcutPolicy: 'orca-first' as 'orca-first' | 'terminal-first'
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
// Minimal React hook runtime: track useState values and useEffect callbacks.
|
||||
const reactRuntime = vi.hoisted(() => ({
|
||||
states: [] as unknown[],
|
||||
index: 0,
|
||||
effects: [] as (() => void | (() => void))[]
|
||||
}))
|
||||
|
||||
vi.mock('react', async () => {
|
||||
const actual = await vi.importActual<typeof import('react')>('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
|
||||
return {
|
||||
...actual,
|
||||
useState<T>(initial: T | (() => T)) {
|
||||
const i = reactRuntime.index++
|
||||
if (!(i in reactRuntime.states)) {
|
||||
reactRuntime.states[i] = typeof initial === 'function' ? (initial as () => T)() : initial
|
||||
}
|
||||
const setState = (next: T | ((prev: T) => T)): void => {
|
||||
reactRuntime.states[i] =
|
||||
typeof next === 'function' ? (next as (p: T) => T)(reactRuntime.states[i] as T) : next
|
||||
}
|
||||
return [reactRuntime.states[i] as T, setState] as const
|
||||
},
|
||||
useEffect(effect: () => void | (() => void)) {
|
||||
reactRuntime.effects.push(effect)
|
||||
},
|
||||
useCallback<T>(fn: T): T {
|
||||
return fn
|
||||
},
|
||||
useMemo<T>(fn: () => T): T {
|
||||
return fn()
|
||||
},
|
||||
useRef<T>(init: T) {
|
||||
return { current: init }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../../../shared/keybindings', () => ({
|
||||
keybindingMatchesAction: keybindingsMock.matchAction
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/shortcut-platform', () => ({
|
||||
getShortcutPlatform: () => 'darwin' as const
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: (selector: (s: typeof appStoreMock.state) => unknown) => selector(appStoreMock.state)
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/useShortcutLabel', () => ({
|
||||
useShortcutKeyComboDetails: () => []
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/terminal-quick-command-search', () => ({
|
||||
searchTerminalQuickCommands: (_cmds: unknown[], _q: string) => [],
|
||||
getTerminalQuickCommandPickerValue: () => null
|
||||
}))
|
||||
|
||||
vi.mock('../../../../shared/terminal-quick-commands', () => ({
|
||||
isTerminalAgentQuickCommand: () => false,
|
||||
getTerminalQuickCommandBody: () => ''
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/agent-catalog', () => ({
|
||||
getAgentLabel: () => '',
|
||||
AgentIcon: () => null
|
||||
}))
|
||||
|
||||
vi.mock('./TabBarQuickCommandItem', () => ({
|
||||
TabBarQuickCommandItem: () => null
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/utils', () => ({
|
||||
cn: (...args: string[]) => args.filter(Boolean).join(' ')
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
translate: (_key: string, fallback: string) => fallback
|
||||
}))
|
||||
|
||||
vi.mock('lucide-react', () => ({
|
||||
ChevronDown: () => null,
|
||||
Play: () => null,
|
||||
Plus: () => null
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/command', () => ({
|
||||
Command: () => null,
|
||||
CommandEmpty: () => null,
|
||||
CommandInput: () => null,
|
||||
CommandList: () => null,
|
||||
CommandSeparator: () => null
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/dropdown-menu', () => ({
|
||||
DropdownMenu: () => null,
|
||||
DropdownMenuContent: () => null,
|
||||
DropdownMenuTrigger: () => null
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/tooltip', () => ({
|
||||
Tooltip: () => null,
|
||||
TooltipContent: () => null,
|
||||
TooltipTrigger: () => null
|
||||
}))
|
||||
|
||||
function makeProps() {
|
||||
return {
|
||||
repoCommands: [] as never[],
|
||||
globalCommands: [] as never[],
|
||||
mostRecent: null,
|
||||
onAddCommand: vi.fn(),
|
||||
onDeleteCommand: vi.fn(),
|
||||
onEditCommand: vi.fn(),
|
||||
onRunCommand: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
function makeKeyEvent(overrides: Partial<KeyboardEvent> = {}): KeyboardEvent {
|
||||
return {
|
||||
preventDefault: vi.fn(),
|
||||
stopImmediatePropagation: vi.fn(),
|
||||
key: 'q',
|
||||
code: 'KeyQ',
|
||||
metaKey: true,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
shiftKey: true,
|
||||
...overrides
|
||||
} as unknown as KeyboardEvent
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
reactRuntime.states = []
|
||||
reactRuntime.index = 0
|
||||
reactRuntime.effects = []
|
||||
windowListeners.clear()
|
||||
keybindingsMock.matchAction.mockClear()
|
||||
keybindingsMock.matchAction.mockReturnValue(false)
|
||||
appStoreMock.state.activeView = 'terminal'
|
||||
appStoreMock.state.keybindings = {}
|
||||
appStoreMock.state.settings.terminalShortcutPolicy = 'orca-first'
|
||||
vi.stubGlobal('window', {
|
||||
addEventListener: vi.fn((type: string, handler: (e: KeyboardEvent) => void) => {
|
||||
windowListeners.set(type, handler)
|
||||
}),
|
||||
removeEventListener: vi.fn()
|
||||
})
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||
cb(0)
|
||||
return 1
|
||||
})
|
||||
vi.stubGlobal('cancelAnimationFrame', vi.fn())
|
||||
})
|
||||
|
||||
describe('TabBarQuickCommandsMenu keyboard shortcut', () => {
|
||||
it('registers a capturing keydown listener on mount', async () => {
|
||||
reactRuntime.index = 0
|
||||
const { TabBarQuickCommandsMenu } = await import('./TabBarQuickCommandsMenu')
|
||||
TabBarQuickCommandsMenu(makeProps())
|
||||
|
||||
// Effect index 0 is the keyboard toggle effect.
|
||||
reactRuntime.effects[0]()
|
||||
|
||||
expect(window.addEventListener).toHaveBeenCalledWith('keydown', expect.any(Function), {
|
||||
capture: true
|
||||
})
|
||||
expect(window.addEventListener).toHaveBeenCalledWith('keyup', expect.any(Function), {
|
||||
capture: true
|
||||
})
|
||||
})
|
||||
|
||||
it('does not register keyboard listeners while the terminal workbench is hidden', async () => {
|
||||
appStoreMock.state.activeView = 'settings'
|
||||
reactRuntime.index = 0
|
||||
const { TabBarQuickCommandsMenu } = await import('./TabBarQuickCommandsMenu')
|
||||
TabBarQuickCommandsMenu(makeProps())
|
||||
|
||||
reactRuntime.effects[0]()
|
||||
|
||||
expect(window.addEventListener).not.toHaveBeenCalledWith('keydown', expect.any(Function), {
|
||||
capture: true
|
||||
})
|
||||
expect(window.addEventListener).not.toHaveBeenCalledWith('keyup', expect.any(Function), {
|
||||
capture: true
|
||||
})
|
||||
})
|
||||
|
||||
it('toggles menuOpen to true when a matching key is pressed', async () => {
|
||||
reactRuntime.index = 0
|
||||
const { TabBarQuickCommandsMenu } = await import('./TabBarQuickCommandsMenu')
|
||||
TabBarQuickCommandsMenu(makeProps())
|
||||
|
||||
reactRuntime.effects[0]()
|
||||
|
||||
keybindingsMock.matchAction.mockReturnValue(true)
|
||||
const handler = windowListeners.get('keydown')
|
||||
expect(handler).toBeDefined()
|
||||
handler!(makeKeyEvent())
|
||||
|
||||
// menuOpen is useState index 0, initial false → toggled to true.
|
||||
expect(reactRuntime.states[0]).toBe(true)
|
||||
})
|
||||
|
||||
it('toggles menuOpen closed when pressed again', async () => {
|
||||
reactRuntime.index = 0
|
||||
const { TabBarQuickCommandsMenu } = await import('./TabBarQuickCommandsMenu')
|
||||
TabBarQuickCommandsMenu(makeProps())
|
||||
reactRuntime.effects[0]()
|
||||
|
||||
keybindingsMock.matchAction.mockReturnValue(true)
|
||||
windowListeners.get('keydown')!(makeKeyEvent())
|
||||
expect(reactRuntime.states[0]).toBe(true)
|
||||
|
||||
// Simulate React re-running the effect after menuOpen changed to true,
|
||||
// so the handler closes over the updated value.
|
||||
reactRuntime.index = 0
|
||||
reactRuntime.effects = []
|
||||
TabBarQuickCommandsMenu(makeProps())
|
||||
reactRuntime.effects[0]()
|
||||
|
||||
windowListeners.get('keydown')!(makeKeyEvent())
|
||||
expect(reactRuntime.states[0]).toBe(false)
|
||||
})
|
||||
|
||||
it('does not toggle when the event is a repeat (key held down)', async () => {
|
||||
reactRuntime.index = 0
|
||||
const { TabBarQuickCommandsMenu } = await import('./TabBarQuickCommandsMenu')
|
||||
TabBarQuickCommandsMenu(makeProps())
|
||||
|
||||
reactRuntime.effects[0]()
|
||||
|
||||
keybindingsMock.matchAction.mockReturnValue(true)
|
||||
const handler = windowListeners.get('keydown')!
|
||||
handler(makeKeyEvent({ repeat: true }))
|
||||
|
||||
expect(reactRuntime.states[0]).toBe(false)
|
||||
})
|
||||
|
||||
it('does not toggle when the key does not match the action', async () => {
|
||||
reactRuntime.index = 0
|
||||
const { TabBarQuickCommandsMenu } = await import('./TabBarQuickCommandsMenu')
|
||||
TabBarQuickCommandsMenu(makeProps())
|
||||
|
||||
reactRuntime.effects[0]()
|
||||
|
||||
keybindingsMock.matchAction.mockReturnValue(false)
|
||||
const handler = windowListeners.get('keydown')!
|
||||
handler(makeKeyEvent())
|
||||
|
||||
expect(reactRuntime.states[0]).toBe(false)
|
||||
})
|
||||
|
||||
it('prevents default and stops propagation for matching keys', async () => {
|
||||
reactRuntime.index = 0
|
||||
const { TabBarQuickCommandsMenu } = await import('./TabBarQuickCommandsMenu')
|
||||
TabBarQuickCommandsMenu(makeProps())
|
||||
|
||||
reactRuntime.effects[0]()
|
||||
|
||||
keybindingsMock.matchAction.mockReturnValue(true)
|
||||
const event = makeKeyEvent()
|
||||
windowListeners.get('keydown')!(event)
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalled()
|
||||
expect(event.stopImmediatePropagation).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes terminal context and terminal-first policy to shortcut matching', async () => {
|
||||
appStoreMock.state.settings.terminalShortcutPolicy = 'terminal-first'
|
||||
reactRuntime.index = 0
|
||||
const { TabBarQuickCommandsMenu } = await import('./TabBarQuickCommandsMenu')
|
||||
TabBarQuickCommandsMenu(makeProps())
|
||||
|
||||
reactRuntime.effects[0]()
|
||||
|
||||
const terminalTarget = {
|
||||
classList: { contains: (className: string) => className === 'xterm-helper-textarea' },
|
||||
closest: () => null
|
||||
} as unknown as EventTarget
|
||||
const handler = windowListeners.get('keydown')!
|
||||
handler(makeKeyEvent({ target: terminalTarget }))
|
||||
|
||||
expect(keybindingsMock.matchAction).toHaveBeenCalledWith(
|
||||
'tab.openQuickCommandsMenu',
|
||||
expect.objectContaining({ key: 'q', code: 'KeyQ' }),
|
||||
'darwin',
|
||||
appStoreMock.state.keybindings,
|
||||
{ context: 'terminal', terminalShortcutPolicy: 'terminal-first' }
|
||||
)
|
||||
expect(reactRuntime.states[0]).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores shortcut presses while the shortcut recorder is active', async () => {
|
||||
reactRuntime.index = 0
|
||||
const { TabBarQuickCommandsMenu } = await import('./TabBarQuickCommandsMenu')
|
||||
TabBarQuickCommandsMenu(makeProps())
|
||||
|
||||
reactRuntime.effects[0]()
|
||||
|
||||
const recorderTarget = {
|
||||
closest: (selector: string) => (selector === '[data-shortcut-recorder-active]' ? {} : null)
|
||||
} as unknown as EventTarget
|
||||
const handler = windowListeners.get('keydown')!
|
||||
handler(makeKeyEvent({ target: recorderTarget }))
|
||||
|
||||
expect(keybindingsMock.matchAction).not.toHaveBeenCalled()
|
||||
expect(reactRuntime.states[0]).toBe(false)
|
||||
})
|
||||
|
||||
it('toggles from a matching double-tap binding in the renderer path', async () => {
|
||||
reactRuntime.index = 0
|
||||
const { TabBarQuickCommandsMenu } = await import('./TabBarQuickCommandsMenu')
|
||||
TabBarQuickCommandsMenu(makeProps())
|
||||
|
||||
reactRuntime.effects[0]()
|
||||
|
||||
keybindingsMock.matchAction.mockImplementation(
|
||||
(_actionId, input: { doubleTapModifier?: string }) => input.doubleTapModifier === 'Shift'
|
||||
)
|
||||
const keyDown = windowListeners.get('keydown')!
|
||||
const keyUp = windowListeners.get('keyup')!
|
||||
const firstDown = makeKeyEvent({
|
||||
key: 'Shift',
|
||||
code: 'ShiftLeft',
|
||||
metaKey: false,
|
||||
shiftKey: true
|
||||
})
|
||||
const firstUp = makeKeyEvent({
|
||||
key: 'Shift',
|
||||
code: 'ShiftLeft',
|
||||
metaKey: false,
|
||||
shiftKey: true
|
||||
})
|
||||
const secondDown = makeKeyEvent({
|
||||
key: 'Shift',
|
||||
code: 'ShiftLeft',
|
||||
metaKey: false,
|
||||
shiftKey: true
|
||||
})
|
||||
|
||||
keyDown(firstDown)
|
||||
keyUp(firstUp)
|
||||
keyDown(secondDown)
|
||||
|
||||
expect(reactRuntime.states[0]).toBe(true)
|
||||
expect(secondDown.preventDefault).toHaveBeenCalled()
|
||||
expect(secondDown.stopImmediatePropagation).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removes the listener when the effect is cleaned up', async () => {
|
||||
reactRuntime.index = 0
|
||||
const { TabBarQuickCommandsMenu } = await import('./TabBarQuickCommandsMenu')
|
||||
TabBarQuickCommandsMenu(makeProps())
|
||||
|
||||
const cleanup = reactRuntime.effects[0]()
|
||||
cleanup?.()
|
||||
|
||||
expect(window.removeEventListener).toHaveBeenCalledWith('keydown', expect.any(Function), {
|
||||
capture: true
|
||||
})
|
||||
expect(window.removeEventListener).toHaveBeenCalledWith('keyup', expect.any(Function), {
|
||||
capture: true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,10 +1,9 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ChevronDown, Pencil, Play, Plus, Trash2 } from 'lucide-react'
|
||||
import { ChevronDown, Play, Plus } from 'lucide-react'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator
|
||||
} from '@/components/ui/command'
|
||||
|
|
@ -14,18 +13,22 @@ import {
|
|||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo'
|
||||
import {
|
||||
getTerminalQuickCommandBody,
|
||||
isTerminalAgentQuickCommand
|
||||
} from '../../../../shared/terminal-quick-commands'
|
||||
import type { TerminalQuickCommand } from '../../../../shared/types'
|
||||
import { AgentIcon, getAgentLabel } from '@/lib/agent-catalog'
|
||||
import { getAgentLabel } from '@/lib/agent-catalog'
|
||||
import { TabBarQuickCommandItem } from './TabBarQuickCommandItem'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
getTerminalQuickCommandPickerValue,
|
||||
searchTerminalQuickCommands
|
||||
} from '@/lib/terminal-quick-command-search'
|
||||
import { useShortcutKeyComboDetails } from '@/hooks/useShortcutLabel'
|
||||
import { useTabBarQuickCommandsShortcut } from './tab-bar-quick-commands-shortcut'
|
||||
type TabBarQuickCommandsMenuProps = {
|
||||
repoCommands: readonly TerminalQuickCommand[]
|
||||
globalCommands: readonly TerminalQuickCommand[]
|
||||
|
|
@ -35,6 +38,7 @@ type TabBarQuickCommandsMenuProps = {
|
|||
onEditCommand: (command: TerminalQuickCommand) => void
|
||||
onRunCommand: (command: TerminalQuickCommand) => void
|
||||
}
|
||||
|
||||
export function TabBarQuickCommandsMenu({
|
||||
repoCommands,
|
||||
globalCommands,
|
||||
|
|
@ -44,12 +48,17 @@ export function TabBarQuickCommandsMenu({
|
|||
onEditCommand,
|
||||
onRunCommand
|
||||
}: TabBarQuickCommandsMenuProps): React.JSX.Element {
|
||||
const openMenuShortcutCombos = useShortcutKeyComboDetails('tab.openQuickCommandsMenu')
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [moreCommandsTooltipOpen, setMoreCommandsTooltipOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [commandValueOverride, setCommandValueOverride] = useState<string | null>(null)
|
||||
const searchInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const commandListRef = useRef<HTMLDivElement | null>(null)
|
||||
const focusFrameRef = useRef<number | null>(null)
|
||||
// Why: closing restores focus to the chevron for accessibility, but that
|
||||
// focus restoration should not immediately reopen its tooltip.
|
||||
const suppressMoreCommandsTooltipRef = useRef(false)
|
||||
const totalVisible = repoCommands.length + globalCommands.length
|
||||
const showSearch = totalVisible > 1
|
||||
const filteredRepoCommands = useMemo(
|
||||
|
|
@ -101,16 +110,36 @@ export function TabBarQuickCommandsMenu({
|
|||
searchInput.setSelectionRange(end, end)
|
||||
})
|
||||
}, [cancelFocusFrame])
|
||||
const handleOpenChange = (next: boolean): void => {
|
||||
setMenuOpen(next)
|
||||
if (next) {
|
||||
setCommandValueOverride(null)
|
||||
const handleMoreCommandsTooltipOpenChange = useCallback((next: boolean): void => {
|
||||
if (next && suppressMoreCommandsTooltipRef.current) {
|
||||
return
|
||||
}
|
||||
cancelFocusFrame()
|
||||
setQuery('')
|
||||
setCommandValueOverride(null)
|
||||
}
|
||||
setMoreCommandsTooltipOpen(next)
|
||||
}, [])
|
||||
const allowMoreCommandsTooltip = useCallback((): void => {
|
||||
suppressMoreCommandsTooltipRef.current = false
|
||||
}, [])
|
||||
const handleOpenChange = useCallback(
|
||||
(next: boolean): void => {
|
||||
setMenuOpen(next)
|
||||
if (next) {
|
||||
suppressMoreCommandsTooltipRef.current = false
|
||||
setMoreCommandsTooltipOpen(false)
|
||||
setCommandValueOverride(null)
|
||||
return
|
||||
}
|
||||
suppressMoreCommandsTooltipRef.current = true
|
||||
setMoreCommandsTooltipOpen(false)
|
||||
cancelFocusFrame()
|
||||
setQuery('')
|
||||
setCommandValueOverride(null)
|
||||
},
|
||||
[cancelFocusFrame]
|
||||
)
|
||||
const closeMenu = useCallback((): void => {
|
||||
handleOpenChange(false)
|
||||
}, [handleOpenChange])
|
||||
useTabBarQuickCommandsShortcut({ menuOpen, onOpenChange: handleOpenChange })
|
||||
useEffect(() => {
|
||||
if (!menuOpen || !showSearch) {
|
||||
return
|
||||
|
|
@ -122,10 +151,10 @@ export function TabBarQuickCommandsMenu({
|
|||
}, [cancelFocusFrame, focusSearchInput, menuOpen, showSearch])
|
||||
const runAndClose = useCallback(
|
||||
(command: TerminalQuickCommand): void => {
|
||||
setMenuOpen(false)
|
||||
closeMenu()
|
||||
onRunCommand(command)
|
||||
},
|
||||
[onRunCommand]
|
||||
[closeMenu, onRunCommand]
|
||||
)
|
||||
const handleSearchKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
|
|
@ -170,72 +199,14 @@ export function TabBarQuickCommandsMenu({
|
|||
},
|
||||
[commandValue, filteredVisibleCommands, runAndClose, selectedCommand]
|
||||
)
|
||||
const moreCommandsLabel = translate(
|
||||
'auto.components.tab.bar.TabBarQuickCommandsButton.b82e237a4b',
|
||||
'More quick commands'
|
||||
)
|
||||
const splitButtonClass =
|
||||
'my-auto flex h-7 shrink-0 items-stretch overflow-hidden rounded-md border border-border/60 text-muted-foreground'
|
||||
const innerButtonBase =
|
||||
'flex items-center bg-transparent leading-none text-muted-foreground hover:bg-accent/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent'
|
||||
const renderItem = (command: TerminalQuickCommand): React.JSX.Element => (
|
||||
<CommandItem
|
||||
key={command.id}
|
||||
value={command.id}
|
||||
onSelect={() => runAndClose(command)}
|
||||
className="group/qc mx-1 my-0.5 items-center gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground"
|
||||
>
|
||||
{isTerminalAgentQuickCommand(command) ? (
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
<AgentIcon agent={command.agent} size={12} />
|
||||
</span>
|
||||
) : (
|
||||
<Play
|
||||
className="size-3 shrink-0 text-muted-foreground"
|
||||
fill="currentColor"
|
||||
strokeWidth={0}
|
||||
/>
|
||||
)}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium text-foreground">{command.label}</span>
|
||||
<span className="block truncate font-mono text-[11px] text-muted-foreground">
|
||||
{isTerminalAgentQuickCommand(command)
|
||||
? `${getAgentLabel(command.agent)}: ${command.prompt}`
|
||||
: command.command}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-0.5 can-hover:opacity-0 transition-opacity group-hover/qc:opacity-100 group-data-[selected=true]/qc:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
setMenuOpen(false)
|
||||
onEditCommand(command)
|
||||
}}
|
||||
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
aria-label={translate(
|
||||
'auto.components.tab.bar.TabBarQuickCommandsButton.15529ede69',
|
||||
'Edit {{value0}}',
|
||||
{ value0: command.label }
|
||||
)}
|
||||
>
|
||||
<Pencil className="size-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
setMenuOpen(false)
|
||||
onDeleteCommand(command)
|
||||
}}
|
||||
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-destructive"
|
||||
aria-label={translate(
|
||||
'auto.components.tab.bar.TabBarQuickCommandsButton.196593b6a9',
|
||||
'Remove {{value0}}',
|
||||
{ value0: command.label }
|
||||
)}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
</CommandItem>
|
||||
)
|
||||
return (
|
||||
<div className={splitButtonClass}>
|
||||
<Tooltip>
|
||||
|
|
@ -288,21 +259,39 @@ export function TabBarQuickCommandsMenu({
|
|||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenu modal={false} open={menuOpen} onOpenChange={handleOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
innerButtonBase,
|
||||
'justify-center rounded-l-none rounded-r-md border-l border-border/60 px-1'
|
||||
)}
|
||||
aria-label={translate(
|
||||
'auto.components.tab.bar.TabBarQuickCommandsButton.b82e237a4b',
|
||||
'More quick commands'
|
||||
)}
|
||||
>
|
||||
<ChevronDown className="size-3" strokeWidth={2.5} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<Tooltip open={moreCommandsTooltipOpen} onOpenChange={handleMoreCommandsTooltipOpenChange}>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
innerButtonBase,
|
||||
'justify-center rounded-l-none rounded-r-md border-l border-border/60 px-1'
|
||||
)}
|
||||
aria-label={moreCommandsLabel}
|
||||
onPointerEnter={allowMoreCommandsTooltip}
|
||||
onBlur={allowMoreCommandsTooltip}
|
||||
>
|
||||
<ChevronDown className="size-3" strokeWidth={2.5} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span>{moreCommandsLabel}</span>
|
||||
{openMenuShortcutCombos.map((shortcut, index) => (
|
||||
<ShortcutKeyCombo
|
||||
key={`${shortcut.keys.join('-')}-${index}`}
|
||||
keys={shortcut.keys}
|
||||
doubleTap={shortcut.doubleTap}
|
||||
className="gap-0.5"
|
||||
keyCapClassName="min-w-0 border-background/30 bg-background/10 px-1 py-0 text-[10px] text-background shadow-none"
|
||||
separatorClassName="mx-0 text-[10px] text-background/70"
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
side="bottom"
|
||||
|
|
@ -358,17 +347,45 @@ export function TabBarQuickCommandsMenu({
|
|||
)}
|
||||
</CommandEmpty>
|
||||
) : null}
|
||||
{filteredRepoCommands.map(renderItem)}
|
||||
{filteredRepoCommands.map((command) => (
|
||||
<TabBarQuickCommandItem
|
||||
key={command.id}
|
||||
command={command}
|
||||
onRun={() => runAndClose(command)}
|
||||
onEdit={() => {
|
||||
closeMenu()
|
||||
onEditCommand(command)
|
||||
}}
|
||||
onDelete={() => {
|
||||
closeMenu()
|
||||
onDeleteCommand(command)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{filteredRepoCommands.length > 0 && filteredGlobalCommands.length > 0 ? (
|
||||
<CommandSeparator className="my-1" />
|
||||
) : null}
|
||||
{filteredGlobalCommands.map(renderItem)}
|
||||
{filteredGlobalCommands.map((command) => (
|
||||
<TabBarQuickCommandItem
|
||||
key={command.id}
|
||||
command={command}
|
||||
onRun={() => runAndClose(command)}
|
||||
onEdit={() => {
|
||||
closeMenu()
|
||||
onEditCommand(command)
|
||||
}}
|
||||
onDelete={() => {
|
||||
closeMenu()
|
||||
onDeleteCommand(command)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</CommandList>
|
||||
<div className="border-t border-border/50 p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMenuOpen(false)
|
||||
closeMenu()
|
||||
onAddCommand()
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-[5px] px-2 py-1.5 text-[12px] text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,150 @@
|
|||
import { useEffect } from 'react'
|
||||
import {
|
||||
keybindingMatchesAction,
|
||||
type KeybindingContext,
|
||||
type KeybindingInput
|
||||
} from '../../../../shared/keybindings'
|
||||
import {
|
||||
ModifierDoubleTapDetector,
|
||||
toModifierDoubleTapEvent
|
||||
} from '../../../../shared/modifier-double-tap-detector'
|
||||
import { getShortcutPlatform } from '@/lib/shortcut-platform'
|
||||
import { TOGGLE_QUICK_COMMANDS_MENU_EVENT } from '@/lib/quick-commands-menu-events'
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
type UseTabBarQuickCommandsShortcutParams = {
|
||||
menuOpen: boolean
|
||||
onOpenChange: (next: boolean) => void
|
||||
}
|
||||
|
||||
function targetHasClass(target: EventTarget | null, className: string): boolean {
|
||||
const classList = (target as { classList?: { contains?: (value: string) => boolean } } | null)
|
||||
?.classList
|
||||
return typeof classList?.contains === 'function' && classList.contains(className)
|
||||
}
|
||||
|
||||
function targetMatchesClosest(target: EventTarget | null, selector: string): boolean {
|
||||
const closest = (target as { closest?: (value: string) => unknown } | null)?.closest
|
||||
return typeof closest === 'function' && Boolean(closest.call(target, selector))
|
||||
}
|
||||
|
||||
function getQuickCommandsShortcutContext(target: EventTarget | null): KeybindingContext {
|
||||
return targetHasClass(target, 'xterm-helper-textarea') ? 'terminal' : 'app'
|
||||
}
|
||||
|
||||
export function useTabBarQuickCommandsShortcut({
|
||||
menuOpen,
|
||||
onOpenChange
|
||||
}: UseTabBarQuickCommandsShortcutParams): void {
|
||||
const keybindings = useAppStore((s) => s.keybindings)
|
||||
const terminalShortcutPolicy = useAppStore(
|
||||
(s) => s.settings?.terminalShortcutPolicy ?? 'orca-first'
|
||||
)
|
||||
const activeView = useAppStore((s) => s.activeView)
|
||||
|
||||
// Why: this hook only runs in the focused tab group's menu component, so the
|
||||
// listener naturally scopes to the active group with no extra coordination.
|
||||
useEffect(() => {
|
||||
if (activeView !== 'terminal') {
|
||||
return
|
||||
}
|
||||
const platform = getShortcutPlatform()
|
||||
const doubleTapDetector = new ModifierDoubleTapDetector()
|
||||
const matchesShortcut = (input: KeybindingInput, target: EventTarget | null): boolean => {
|
||||
const context = getQuickCommandsShortcutContext(target)
|
||||
return keybindingMatchesAction('tab.openQuickCommandsMenu', input, platform, keybindings, {
|
||||
context,
|
||||
terminalShortcutPolicy
|
||||
})
|
||||
}
|
||||
const toggleMenu = (e: KeyboardEvent): void => {
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
onOpenChange(!menuOpen)
|
||||
}
|
||||
const onKeyDown = (e: KeyboardEvent): void => {
|
||||
if (targetMatchesClosest(e.target, '[data-shortcut-recorder-active]')) {
|
||||
doubleTapDetector.reset()
|
||||
return
|
||||
}
|
||||
const detected = doubleTapDetector.process(
|
||||
toModifierDoubleTapEvent({
|
||||
type: 'keyDown',
|
||||
code: e.code,
|
||||
key: e.key,
|
||||
shift: e.shiftKey,
|
||||
control: e.ctrlKey,
|
||||
alt: e.altKey,
|
||||
meta: e.metaKey,
|
||||
isAutoRepeat: e.repeat
|
||||
}),
|
||||
Date.now()
|
||||
)
|
||||
if (detected) {
|
||||
if (matchesShortcut({ doubleTapModifier: detected.modifier }, e.target)) {
|
||||
toggleMenu(e)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (e.repeat) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
!matchesShortcut(
|
||||
{
|
||||
key: e.key,
|
||||
code: e.code,
|
||||
altKey: e.altKey,
|
||||
metaKey: e.metaKey,
|
||||
ctrlKey: e.ctrlKey,
|
||||
shiftKey: e.shiftKey
|
||||
},
|
||||
e.target
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
toggleMenu(e)
|
||||
}
|
||||
const onKeyUp = (e: KeyboardEvent): void => {
|
||||
if (targetMatchesClosest(e.target, '[data-shortcut-recorder-active]')) {
|
||||
doubleTapDetector.reset()
|
||||
return
|
||||
}
|
||||
doubleTapDetector.process(
|
||||
toModifierDoubleTapEvent({
|
||||
type: 'keyUp',
|
||||
code: e.code,
|
||||
key: e.key,
|
||||
shift: e.shiftKey,
|
||||
control: e.ctrlKey,
|
||||
alt: e.altKey,
|
||||
meta: e.metaKey
|
||||
}),
|
||||
Date.now()
|
||||
)
|
||||
}
|
||||
const onBlur = (): void => doubleTapDetector.reset()
|
||||
window.addEventListener('keydown', onKeyDown, { capture: true })
|
||||
window.addEventListener('keyup', onKeyUp, { capture: true })
|
||||
window.addEventListener('blur', onBlur)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown, { capture: true })
|
||||
window.removeEventListener('keyup', onKeyUp, { capture: true })
|
||||
window.removeEventListener('blur', onBlur)
|
||||
}
|
||||
}, [activeView, keybindings, menuOpen, onOpenChange, terminalShortcutPolicy])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeView !== 'terminal') {
|
||||
return
|
||||
}
|
||||
const onToggleQuickCommandsMenu = (): void => {
|
||||
onOpenChange(!menuOpen)
|
||||
}
|
||||
window.addEventListener(TOGGLE_QUICK_COMMANDS_MENU_EVENT, onToggleQuickCommandsMenu)
|
||||
return () => {
|
||||
window.removeEventListener(TOGGLE_QUICK_COMMANDS_MENU_EVENT, onToggleQuickCommandsMenu)
|
||||
}
|
||||
}, [activeView, menuOpen, onOpenChange])
|
||||
}
|
||||
|
|
@ -125,7 +125,10 @@ export function resyncTerminalFocusForWindowFocus(args: {
|
|||
const schedule = args.scheduleRefocus ?? scheduleNextFrame
|
||||
schedule(() => {
|
||||
const active = reclaimedHelper.ownerDocument.activeElement
|
||||
if (active === reclaimedHelper || isDocumentBodyOrNull(active, reclaimedHelper.ownerDocument)) {
|
||||
if (
|
||||
active === reclaimedHelper ||
|
||||
isDocumentBodyOrNull(active, reclaimedHelper.ownerDocument)
|
||||
) {
|
||||
reclaimedHelper.focus()
|
||||
}
|
||||
})
|
||||
|
|
@ -144,7 +147,10 @@ export function resyncTerminalFocusForWindowFocus(args: {
|
|||
// Why: only reclaim focus if nothing else grabbed it during the frame, so
|
||||
// a click into another field mid-reactivation isn't yanked back.
|
||||
const active = reclaimedHelper.ownerDocument.activeElement
|
||||
if (active === reclaimedHelper || isDocumentBodyOrNull(active, reclaimedHelper.ownerDocument)) {
|
||||
if (
|
||||
active === reclaimedHelper ||
|
||||
isDocumentBodyOrNull(active, reclaimedHelper.ownerDocument)
|
||||
) {
|
||||
reclaimedHelper.focus()
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -948,6 +948,7 @@ describe('useIpcEvents browser tab create routing', () => {
|
|||
onToggleWorktreePalette: () => () => {},
|
||||
onToggleFloatingTerminal: () => () => {},
|
||||
onOpenQuickOpen: () => () => {},
|
||||
onToggleQuickCommandsMenu: () => () => {},
|
||||
onOpenNewWorkspace: () => () => {},
|
||||
onOpenTasks: () => () => {},
|
||||
onJumpToWorktreeIndex: () => () => {},
|
||||
|
|
@ -1167,6 +1168,7 @@ describe('useIpcEvents updater integration', () => {
|
|||
onToggleWorktreePalette: () => () => {},
|
||||
onToggleFloatingTerminal: () => () => {},
|
||||
onOpenQuickOpen: () => () => {},
|
||||
onToggleQuickCommandsMenu: () => () => {},
|
||||
onOpenNewWorkspace: () => () => {},
|
||||
onOpenTasks: () => () => {},
|
||||
onJumpToWorktreeIndex: () => () => {},
|
||||
|
|
@ -1409,6 +1411,7 @@ describe('useIpcEvents updater integration', () => {
|
|||
onToggleWorktreePalette: () => () => {},
|
||||
onToggleFloatingTerminal: () => () => {},
|
||||
onOpenQuickOpen: () => () => {},
|
||||
onToggleQuickCommandsMenu: () => () => {},
|
||||
onOpenNewWorkspace: () => () => {},
|
||||
onOpenTasks: () => () => {},
|
||||
onJumpToWorktreeIndex: () => () => {},
|
||||
|
|
@ -1840,6 +1843,7 @@ describe('useIpcEvents updater integration', () => {
|
|||
onToggleWorktreePalette: () => () => {},
|
||||
onToggleFloatingTerminal: () => () => {},
|
||||
onOpenQuickOpen: () => () => {},
|
||||
onToggleQuickCommandsMenu: () => () => {},
|
||||
onOpenNewWorkspace: () => () => {},
|
||||
onOpenTasks: () => () => {},
|
||||
onJumpToWorktreeIndex: () => () => {},
|
||||
|
|
@ -2731,6 +2735,7 @@ describe('useIpcEvents browser tab close routing', () => {
|
|||
onToggleWorktreePalette: () => () => {},
|
||||
onToggleFloatingTerminal: () => () => {},
|
||||
onOpenQuickOpen: () => () => {},
|
||||
onToggleQuickCommandsMenu: () => () => {},
|
||||
onOpenNewWorkspace: () => () => {},
|
||||
onOpenTasks: () => () => {},
|
||||
onJumpToWorktreeIndex: () => () => {},
|
||||
|
|
@ -3218,6 +3223,7 @@ describe('useIpcEvents browser tab close routing', () => {
|
|||
onToggleWorktreePalette: () => () => {},
|
||||
onToggleFloatingTerminal: () => () => {},
|
||||
onOpenQuickOpen: () => () => {},
|
||||
onToggleQuickCommandsMenu: () => () => {},
|
||||
onOpenNewWorkspace: () => () => {},
|
||||
onOpenTasks: () => () => {},
|
||||
onJumpToWorktreeIndex: () => () => {},
|
||||
|
|
@ -3433,6 +3439,7 @@ describe('useIpcEvents browser tab close routing', () => {
|
|||
onToggleWorktreePalette: () => () => {},
|
||||
onToggleFloatingTerminal: () => () => {},
|
||||
onOpenQuickOpen: () => () => {},
|
||||
onToggleQuickCommandsMenu: () => () => {},
|
||||
onOpenNewWorkspace: () => () => {},
|
||||
onOpenTasks: () => () => {},
|
||||
onJumpToWorktreeIndex: () => () => {},
|
||||
|
|
@ -3643,6 +3650,7 @@ describe('useIpcEvents browser tab close routing', () => {
|
|||
onToggleWorktreePalette: () => () => {},
|
||||
onToggleFloatingTerminal: () => () => {},
|
||||
onOpenQuickOpen: () => () => {},
|
||||
onToggleQuickCommandsMenu: () => () => {},
|
||||
onOpenNewWorkspace: () => () => {},
|
||||
onOpenTasks: () => () => {},
|
||||
onJumpToWorktreeIndex: () => () => {},
|
||||
|
|
@ -3871,6 +3879,7 @@ describe('useIpcEvents CLI-created worktree activation', () => {
|
|||
onToggleWorktreePalette: () => () => {},
|
||||
onToggleFloatingTerminal: () => () => {},
|
||||
onOpenQuickOpen: () => () => {},
|
||||
onToggleQuickCommandsMenu: () => () => {},
|
||||
onOpenNewWorkspace: () => () => {},
|
||||
onOpenTasks: () => () => {},
|
||||
onJumpToWorktreeIndex: () => () => {},
|
||||
|
|
@ -4125,6 +4134,7 @@ describe('useIpcEvents CLI-created worktree activation', () => {
|
|||
onToggleWorktreePalette: () => () => {},
|
||||
onToggleFloatingTerminal: () => () => {},
|
||||
onOpenQuickOpen: () => () => {},
|
||||
onToggleQuickCommandsMenu: () => () => {},
|
||||
onOpenNewWorkspace: () => () => {},
|
||||
onOpenTasks: () => () => {},
|
||||
onJumpToWorktreeIndex: () => () => {},
|
||||
|
|
@ -4356,6 +4366,7 @@ describe('useIpcEvents agent status snapshot integration', () => {
|
|||
onToggleWorktreePalette: () => () => {},
|
||||
onToggleFloatingTerminal: () => () => {},
|
||||
onOpenQuickOpen: () => () => {},
|
||||
onToggleQuickCommandsMenu: () => () => {},
|
||||
onOpenNewWorkspace: () => () => {},
|
||||
onOpenTasks: () => () => {},
|
||||
onJumpToWorktreeIndex: () => () => {},
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ import {
|
|||
} from '../../../shared/agent-status-identity'
|
||||
import { isGitRepoKind } from '../../../shared/repo-kind'
|
||||
import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal'
|
||||
import { TOGGLE_QUICK_COMMANDS_MENU_EVENT } from '@/lib/quick-commands-menu-events'
|
||||
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
|
||||
import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane'
|
||||
import { focusRuntimeTerminalSurface } from '@/runtime/sync-runtime-graph'
|
||||
|
|
@ -1230,6 +1231,12 @@ export function useIpcEvents(): void {
|
|||
})
|
||||
)
|
||||
|
||||
unsubs.push(
|
||||
window.api.ui.onToggleQuickCommandsMenu(() => {
|
||||
window.dispatchEvent(new CustomEvent(TOGGLE_QUICK_COMMANDS_MENU_EVENT))
|
||||
})
|
||||
)
|
||||
|
||||
unsubs.push(
|
||||
window.api.ui.onOpenNewWorkspace(() => {
|
||||
const store = useAppStore.getState()
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
export const TOGGLE_QUICK_COMMANDS_MENU_EVENT = 'orca:toggleQuickCommandsMenu'
|
||||
|
|
@ -2170,6 +2170,7 @@ function createWebUiApi(): NonNullable<Partial<PreloadApi>['ui']> {
|
|||
onToggleFloatingTerminal: () => noopUnsubscribe,
|
||||
onTerminalShortcutCaptured: () => noopUnsubscribe,
|
||||
onOpenQuickOpen: () => noopUnsubscribe,
|
||||
onToggleQuickCommandsMenu: () => noopUnsubscribe,
|
||||
onOpenTasks: () => noopUnsubscribe,
|
||||
onOpenNewWorkspace: () => noopUnsubscribe,
|
||||
onDeleteCurrentWorkspace: () => noopUnsubscribe,
|
||||
|
|
|
|||
|
|
@ -224,6 +224,71 @@ describe('keybindings', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('reports quick-command menu conflicts with global shortcuts and digit ranges', () => {
|
||||
expect(
|
||||
findKeybindingConflicts('darwin', {
|
||||
'tab.openQuickCommandsMenu': ['Mod+P']
|
||||
})
|
||||
).toContainEqual({
|
||||
binding: 'Mod+P',
|
||||
actionIds: expect.arrayContaining(['worktree.quickOpen', 'tab.openQuickCommandsMenu'])
|
||||
})
|
||||
|
||||
expect(
|
||||
findKeybindingConflicts('darwin', {
|
||||
'tab.openQuickCommandsMenu': ['Cmd+P']
|
||||
})
|
||||
).toContainEqual({
|
||||
binding: 'Mod+P',
|
||||
actionIds: expect.arrayContaining(['worktree.quickOpen', 'tab.openQuickCommandsMenu'])
|
||||
})
|
||||
|
||||
expect(
|
||||
findKeybindingConflicts('linux', {
|
||||
'tab.openQuickCommandsMenu': ['Ctrl+P']
|
||||
})
|
||||
).toContainEqual({
|
||||
binding: 'Mod+P',
|
||||
actionIds: expect.arrayContaining(['worktree.quickOpen', 'tab.openQuickCommandsMenu'])
|
||||
})
|
||||
|
||||
expect(
|
||||
findKeybindingConflicts('darwin', {
|
||||
'tab.openQuickCommandsMenu': ['Mod+3']
|
||||
})
|
||||
).toContainEqual({
|
||||
binding: 'Mod+3',
|
||||
actionIds: expect.arrayContaining(['workspace.selectByIndex', 'tab.openQuickCommandsMenu'])
|
||||
})
|
||||
|
||||
expect(
|
||||
findKeybindingConflicts('darwin', {
|
||||
'tab.openQuickCommandsMenu': ['Cmd+3']
|
||||
})
|
||||
).toContainEqual({
|
||||
binding: 'Cmd+3',
|
||||
actionIds: expect.arrayContaining(['workspace.selectByIndex', 'tab.openQuickCommandsMenu'])
|
||||
})
|
||||
|
||||
expect(
|
||||
findKeybindingConflicts('linux', {
|
||||
'tab.openQuickCommandsMenu': ['Ctrl+3']
|
||||
})
|
||||
).toContainEqual({
|
||||
binding: 'Ctrl+3',
|
||||
actionIds: expect.arrayContaining(['workspace.selectByIndex', 'tab.openQuickCommandsMenu'])
|
||||
})
|
||||
|
||||
expect(
|
||||
findKeybindingConflicts('linux', {
|
||||
'tab.openQuickCommandsMenu': ['Alt+4']
|
||||
})
|
||||
).toContainEqual({
|
||||
binding: 'Alt+4',
|
||||
actionIds: expect.arrayContaining(['tab.selectByIndex', 'tab.openQuickCommandsMenu'])
|
||||
})
|
||||
})
|
||||
|
||||
it('defines macOS-only rename shortcuts that stay conflict-free', () => {
|
||||
expect(getEffectiveKeybindingsForAction('tab.rename', 'darwin')).toEqual(['Mod+R'])
|
||||
expect(getEffectiveKeybindingsForAction('tab.rename', 'linux')).toEqual([])
|
||||
|
|
@ -494,6 +559,38 @@ describe('keybindings', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('keeps the quick commands menu toggle unassigned until users customize it', () => {
|
||||
const platforms: readonly KeybindingPlatform[] = ['darwin', 'linux', 'win32']
|
||||
|
||||
for (const platform of platforms) {
|
||||
expect(getEffectiveKeybindingsForAction('tab.openQuickCommandsMenu', platform)).toEqual([])
|
||||
}
|
||||
|
||||
const binding = {
|
||||
key: 'q',
|
||||
code: 'KeyQ',
|
||||
control: true,
|
||||
meta: false,
|
||||
alt: false,
|
||||
shift: true
|
||||
}
|
||||
|
||||
expect(keybindingMatchesAction('tab.openQuickCommandsMenu', binding, 'linux')).toBe(false)
|
||||
expect(
|
||||
keybindingMatchesAction('tab.openQuickCommandsMenu', binding, 'linux', {
|
||||
'tab.openQuickCommandsMenu': ['Mod+Shift+Q']
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
const definition = getKeybindingDefinition('tab.openQuickCommandsMenu')
|
||||
expect(definition?.title).toBe('Toggle Quick Commands menu')
|
||||
expect(definition?.group).toBe('Quick Commands')
|
||||
expect(definition?.scope).toBe('tabs')
|
||||
expect(definition?.searchKeywords).toEqual(
|
||||
expect.arrayContaining(['shortcut', 'quick', 'command', 'menu', 'tab'])
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the sleeping-workspaces toggle unassigned until users customize it', () => {
|
||||
const binding = {
|
||||
key: 's',
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ export type KeybindingActionId =
|
|||
| 'tab.nextTerminal'
|
||||
| 'tab.previousTerminal'
|
||||
| 'tab.selectByIndex'
|
||||
| 'tab.openQuickCommandsMenu'
|
||||
| 'browser.find'
|
||||
| 'browser.back'
|
||||
| 'browser.forward'
|
||||
|
|
@ -704,6 +705,17 @@ export const KEYBINDING_DEFINITIONS: readonly KeybindingDefinition[] = [
|
|||
win32: ['Alt+1']
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'tab.openQuickCommandsMenu',
|
||||
title: 'Toggle Quick Commands menu',
|
||||
group: 'Quick Commands',
|
||||
scope: 'tabs',
|
||||
// Why: this tab-scoped action is also routed through the main window
|
||||
// shortcut allowlist, so Settings must warn when it shadows global chords.
|
||||
conflictGroup: 'global',
|
||||
searchKeywords: ['shortcut', 'quick', 'command', 'menu', 'tab', 'group', 'toggle'],
|
||||
defaultBindings: platformBindings([])
|
||||
},
|
||||
{
|
||||
id: 'browser.find',
|
||||
title: 'Find in Browser',
|
||||
|
|
@ -1986,12 +1998,44 @@ export function keybindingMatchesInput(
|
|||
)
|
||||
}
|
||||
|
||||
function keybindingConflictIdentityForParsed(
|
||||
parsed: ParsedKeybinding,
|
||||
platform: NodeJS.Platform
|
||||
): string {
|
||||
if (parsed.doubleTapModifier) {
|
||||
return `DoubleTap:${resolveModifierToken(parsed.doubleTapModifier, platform)}`
|
||||
}
|
||||
const modifiers = platformModifiers(parsed, platform)
|
||||
return [
|
||||
modifiers.meta ? 'Meta' : '',
|
||||
modifiers.control ? 'Control' : '',
|
||||
modifiers.alt ? 'Alt' : '',
|
||||
modifiers.shift ? 'Shift' : '',
|
||||
parsed.key
|
||||
].join('+')
|
||||
}
|
||||
|
||||
function keybindingConflictIdentity(binding: string, platform: NodeJS.Platform): string {
|
||||
const parsed = parseKeybinding(binding)
|
||||
if (!parsed?.doubleTapModifier) {
|
||||
return binding
|
||||
return parsed ? keybindingConflictIdentityForParsed(parsed, platform) : binding
|
||||
}
|
||||
|
||||
function keybindingConflictIdentities(
|
||||
actionId: KeybindingActionId,
|
||||
binding: string,
|
||||
platform: NodeJS.Platform
|
||||
): readonly string[] {
|
||||
const exact = keybindingConflictIdentity(binding, platform)
|
||||
if (!isDigitIndexActionId(actionId)) {
|
||||
return [exact]
|
||||
}
|
||||
return `DoubleTap:${resolveModifierToken(parsed.doubleTapModifier, platform)}`
|
||||
const parsed = parseKeybinding(binding)
|
||||
if (!parsed || parsed.doubleTapModifier || !DIGIT_INDEX_KEY_PATTERN.test(parsed.key)) {
|
||||
return [exact]
|
||||
}
|
||||
return Array.from({ length: 9 }, (_, index) =>
|
||||
keybindingConflictIdentityForParsed({ ...parsed, key: String(index + 1) }, platform)
|
||||
)
|
||||
}
|
||||
|
||||
export function keybindingMatchesAction(
|
||||
|
|
@ -2175,20 +2219,37 @@ export function findKeybindingConflicts(
|
|||
groups.add(definition.scope)
|
||||
}
|
||||
for (const group of groups) {
|
||||
const conflictKey = `${group}\u0000${keybindingConflictIdentity(binding, platform)}`
|
||||
const current = owners.get(conflictKey) ?? { binding, actionIds: new Set() }
|
||||
current.actionIds.add(definition.id)
|
||||
owners.set(conflictKey, current)
|
||||
for (const identity of keybindingConflictIdentities(definition.id, binding, platform)) {
|
||||
const conflictKey = `${group}\u0000${identity}`
|
||||
const current = owners.get(conflictKey) ?? { binding, actionIds: new Set() }
|
||||
if (
|
||||
!isDigitIndexActionId(definition.id) &&
|
||||
Array.from(current.actionIds).some((actionId) => isDigitIndexActionId(actionId))
|
||||
) {
|
||||
current.binding = binding
|
||||
}
|
||||
current.actionIds.add(definition.id)
|
||||
owners.set(conflictKey, current)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const seenConflictKeys = new Set<string>()
|
||||
return Array.from(owners.values())
|
||||
.filter(({ actionIds }) => actionIds.size > 1 && setIntersects(actionIds, customizedActions))
|
||||
.map(({ binding, actionIds }) => ({
|
||||
binding,
|
||||
actionIds: Array.from(actionIds)
|
||||
}))
|
||||
.filter((conflict) => {
|
||||
const key = `${conflict.binding}\u0000${conflict.actionIds.join('\u0000')}`
|
||||
if (seenConflictKeys.has(key)) {
|
||||
return false
|
||||
}
|
||||
seenConflictKeys.add(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function setIntersects<T>(left: ReadonlySet<T>, right: ReadonlySet<T>): boolean {
|
||||
|
|
|
|||
|
|
@ -104,6 +104,54 @@ describe('resolveWindowShortcutAction', () => {
|
|||
).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves customized quick-command menu shortcuts with terminal policy gating', () => {
|
||||
const input: WindowShortcutInput = {
|
||||
code: 'KeyQ',
|
||||
key: 'q',
|
||||
meta: false,
|
||||
control: true,
|
||||
alt: false,
|
||||
shift: true
|
||||
}
|
||||
const overrides: KeybindingOverrides = {
|
||||
'tab.openQuickCommandsMenu': ['Mod+Shift+Q']
|
||||
}
|
||||
|
||||
expect(resolveWindowShortcutAction(input, 'linux', overrides)).toEqual({
|
||||
type: 'toggleQuickCommandsMenu'
|
||||
})
|
||||
expect(
|
||||
resolveWindowShortcutAction(input, 'linux', overrides, {
|
||||
context: 'terminal',
|
||||
terminalShortcutPolicy: 'terminal-first'
|
||||
})
|
||||
).toBeNull()
|
||||
expect(
|
||||
resolveWindowShortcutAction(input, 'linux', overrides, {
|
||||
context: 'terminal',
|
||||
terminalShortcutPolicy: 'orca-first'
|
||||
})
|
||||
).toEqual({ type: 'toggleQuickCommandsMenu' })
|
||||
})
|
||||
|
||||
it('keeps digit-index navigation ahead of customized quick-command shortcuts', () => {
|
||||
expect(
|
||||
resolveWindowShortcutAction(
|
||||
{ code: 'Digit3', key: '3', meta: true, control: false, alt: false, shift: false },
|
||||
'darwin',
|
||||
{ 'tab.openQuickCommandsMenu': ['Mod+3'] }
|
||||
)
|
||||
).toEqual({ type: 'jumpToWorktreeIndex', index: 2 })
|
||||
|
||||
expect(
|
||||
resolveWindowShortcutAction(
|
||||
{ code: 'Digit4', key: '4', meta: false, control: false, alt: true, shift: false },
|
||||
'linux',
|
||||
{ 'tab.openQuickCommandsMenu': ['Alt+4'] }
|
||||
)
|
||||
).toEqual({ type: 'jumpToTabIndex', index: 3 })
|
||||
})
|
||||
|
||||
it('honors remapped tab/workspace number ranges, including swapping the modifiers', () => {
|
||||
// Swap on macOS: tab now uses Cmd+1-9, workspace uses Ctrl+1-9.
|
||||
const swapped: KeybindingOverrides = {
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ export type WindowShortcutAction =
|
|||
| { type: 'toggleLeftSidebar' }
|
||||
| { type: 'toggleRightSidebar' }
|
||||
| { type: 'openQuickOpen' }
|
||||
| { type: 'toggleQuickCommandsMenu' }
|
||||
| { type: 'openNewWorkspace' }
|
||||
| { type: 'deleteCurrentWorkspace' }
|
||||
| { type: 'openWorkspaceBoard' }
|
||||
|
|
@ -248,6 +249,10 @@ export function resolveWindowShortcutAction(
|
|||
return { type: 'jumpToTabIndex', index: tabIndex }
|
||||
}
|
||||
|
||||
if (actionMatches('tab.openQuickCommandsMenu', input, platform, keybindings, options)) {
|
||||
return { type: 'toggleQuickCommandsMenu' }
|
||||
}
|
||||
|
||||
// Why: this helper is the explicit allowlist for main-process interception.
|
||||
// Anything not listed here must keep flowing to the renderer/PTTY so readline
|
||||
// chords like Ctrl+R, Ctrl+U, and Ctrl+E are not accidentally stolen while
|
||||
|
|
@ -277,6 +282,8 @@ export function getWindowShortcutActionId(action: WindowShortcutAction): Keybind
|
|||
return 'sidebar.right.toggle'
|
||||
case 'openQuickOpen':
|
||||
return 'worktree.quickOpen'
|
||||
case 'toggleQuickCommandsMenu':
|
||||
return 'tab.openQuickCommandsMenu'
|
||||
case 'openNewWorkspace':
|
||||
return 'workspace.create'
|
||||
case 'deleteCurrentWorkspace':
|
||||
|
|
|
|||
Loading…
Reference in New Issue