Files
orca/src/shared/keybindings/matching.ts
T
Brennan Benson 62975d6bfe fix(sidebar): run previous and next worktree shortcuts from a text field (#28891)
* fix(sidebar): run previous and next worktree shortcuts from a text field

The worktree list swallowed every key while a text field was focused, so the bound shortcut did nothing in chat, markdown, and the code editor.

* fix(sidebar): keep the default worktree chord with text fields

Previous/next worktree now carries a text-field rule in its keybinding definition. In a text field the default Cmd/Ctrl+Shift+Up/Down keeps its select-to-start/end meaning, while a chord the user bound still switches worktrees from chat, markdown and the code editor. Option and AltGr chords that type a character stay with the field.

* fix(sidebar): let user-bound Option arrow chords leave text fields

On Mac only Option chords that type a character or dead key stay with a focused text field, so a bound Option+Down now switches worktrees from chat, markdown and the code editor.
2026-10-18 22:26:24 -07:00

160 lines
4.8 KiB
TypeScript

import type {
KeybindingActionId,
KeybindingInput,
KeybindingOverrides,
KeybindingMatchOptions,
ParsedKeybinding
} from './types'
import { DEFINITIONS_BY_ID, DIGIT_INDEX_KEY_PATTERN, isDigitIndexActionId } from './definitions'
import { canonicalizeParsedKeybinding, parseKeybinding } from './parser'
import {
platformModifiers,
modifierStateMatches,
keyMatches,
digitKeyMatches,
resolveModifierToken
} from './matching-key'
import {
getDefaultBindings,
getEffectiveKeybindingsForAction,
keybindingIsActiveInContext
} from './effective'
export function keybindingMatchesInput(
binding: string,
input: KeybindingInput,
platform: NodeJS.Platform
): boolean {
const parsed = parseKeybinding(binding)
if (!parsed) {
return false
}
// A double-tap binding matches only a synthetic double-tap input (and vice-versa), resolved per platform.
if (parsed.doubleTapModifier) {
return (
input.doubleTapModifier !== undefined &&
resolveModifierToken(parsed.doubleTapModifier, platform) ===
resolveModifierToken(input.doubleTapModifier, platform)
)
}
if (input.doubleTapModifier !== undefined) {
return false
}
return (
modifierStateMatches(parsed, input, platform) && keyMatches(parsed.key, input, parsed, platform)
)
}
export 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('+')
}
export function getKeybindingConflictIdentity(binding: string, platform: NodeJS.Platform): string {
const parsed = parseKeybinding(binding)
return parsed ? keybindingConflictIdentityForParsed(parsed, platform) : binding
}
export function keybindingConflictIdentities(
actionId: KeybindingActionId,
binding: string,
platform: NodeJS.Platform
): readonly string[] {
const exact = getKeybindingConflictIdentity(binding, platform)
if (!isDigitIndexActionId(actionId)) {
return [exact]
}
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(
actionId: KeybindingActionId,
input: KeybindingInput,
platform: NodeJS.Platform,
overrides?: KeybindingOverrides,
options: KeybindingMatchOptions = {}
): boolean {
const definition = DEFINITIONS_BY_ID.get(actionId)
if (!definition) {
return false
}
if (!keybindingIsActiveInContext(definition, options)) {
return false
}
return getEffectiveKeybindingsForAction(actionId, platform, overrides).some((binding) =>
keybindingMatchesInput(binding, input, platform)
)
}
// Why: decided from the definition and the pressed chord, so re-saving a default never takes it from the field.
export function keybindingRunsInTextField(
actionId: KeybindingActionId,
input: KeybindingInput,
platform: NodeJS.Platform
): boolean {
const definition = DEFINITIONS_BY_ID.get(actionId)
return (
definition?.allowInTextField === 'non-default-chords' &&
!getDefaultBindings(definition, platform).some((binding) =>
keybindingMatchesInput(binding, input, platform)
)
)
}
export function digitFromInput(input: KeybindingInput, platform: NodeJS.Platform): string | null {
for (let value = 1; value <= 9; value++) {
const digit = String(value)
if (digitKeyMatches(input, digit, platform)) {
return digit
}
}
return null
}
// Why: a digit-index row's representative chord fires for any 1-9 — reuse its modifiers with the pressed digit via the normal matcher.
export function matchKeybindingDigitIndex(
actionId: KeybindingActionId,
input: KeybindingInput,
platform: NodeJS.Platform,
overrides?: KeybindingOverrides,
options: KeybindingMatchOptions = {}
): number | null {
const definition = DEFINITIONS_BY_ID.get(actionId)
if (!definition || !keybindingIsActiveInContext(definition, options)) {
return null
}
const digit = digitFromInput(input, platform)
if (!digit) {
return null
}
for (const binding of getEffectiveKeybindingsForAction(actionId, platform, overrides)) {
const parsed = parseKeybinding(binding)
if (!parsed || parsed.doubleTapModifier || !DIGIT_INDEX_KEY_PATTERN.test(parsed.key)) {
continue
}
const candidate = canonicalizeParsedKeybinding({ ...parsed, key: digit })
if (keybindingMatchesInput(candidate, input, platform)) {
return Number(digit) - 1
}
}
return null
}