feat(shortcuts): double-tap modifier keybindings (#5516)
* docs: design for double-tap modifier keybindings * feat(keybindings): parse and normalize DoubleTap+<Mod> grammar * test(keybindings): cover DoubleTap+Ctrl positive case * feat(keybindings): match DoubleTap bindings against synthetic input * test(keybindings): cover Ctrl-on-darwin double-tap miss; document input invariant * feat(keybindings): format DoubleTap bindings as a doubled glyph * test(keybindings): cover DoubleTap+Ctrl glyph on mac; note title label semantics * feat(keybindings): add pure ModifierDoubleTapDetector state machine * test(keybindings): cover missed-keyup double-tap edge; clarify keyUp guard * feat(keybindings): capture double-tap gestures into DoubleTap+<Mod> * test(keybindings): cover Ctrl/linux and Cmd/linux double-tap canonicalization * feat(keybindings): resolve double-tap input in window shortcut policy * feat(shortcuts): detect double-tap modifiers in main before-input-event * docs(shortcuts): note dictation guards live in the renderer * feat(shortcuts): dispatch double-tap modifiers in the renderer window handler * fix(shortcuts): clear armed double-tap state on dangling modifier keyup * feat(settings): record double-tap modifier shortcuts in the editor * i18n(shortcuts): localize ShortcutKeyCombo double-tap tooltip * Fix double-tap shortcut review gaps Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Devin345458 <devin@appleidimagination.com> Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com> Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
0eec0ad74a
commit
1368ba84b8
|
|
@ -0,0 +1,247 @@
|
|||
# Double-tap modifier keybindings — design
|
||||
|
||||
## Goal
|
||||
|
||||
Allow any keybinding action to be bound to a **double-tap of a bare modifier**
|
||||
(Shift, Cmd/Ctrl, Alt) in Settings → Shortcuts. A double-tap binding is stored,
|
||||
recorded, formatted, conflict-checked, and matched alongside normal bindings,
|
||||
and fires everywhere a normal shortcut does — including when a browser guest or
|
||||
terminal owns focus.
|
||||
|
||||
Example: bind `DoubleTap+Shift` to `worktree.quickOpen` ("Go to File"), then
|
||||
tapping Shift twice opens Go to File (IntelliJ "double-Shift" style).
|
||||
|
||||
## Why this is not just another binding
|
||||
|
||||
The existing keybinding system is **stateless and per-keydown**: every binding
|
||||
has at least one modifier and exactly one key, and matching compares a single
|
||||
`KeyboardEvent`'s modifier state + key against the stored binding string
|
||||
(`keybindingMatchesInput` in `src/shared/keybindings.ts`). A double-tap is a
|
||||
**timed sequence of a bare modifier with no key** — press M, release M, press M
|
||||
again within a short window. It cannot be represented by the current grammar or
|
||||
matched by the current stateless comparison.
|
||||
|
||||
Dispatch is also split across two layers, and both must participate for a
|
||||
double-tap to work for "any action":
|
||||
|
||||
- **Main process** — `before-input-event` in
|
||||
`src/main/window/createMainWindow.ts` matches an explicit allowlist of ~20
|
||||
actions via `resolveWindowShortcutAction`
|
||||
(`src/shared/window-shortcut-policy.ts`), calls `preventDefault()`, and
|
||||
forwards the action to the renderer over IPC. This layer exists so a subset of
|
||||
shortcuts work even when focus lives in a browser guest `webContents` or a
|
||||
contentEditable surface that bypasses the renderer's window-level listener.
|
||||
- **Renderer** — the window `keydown` handler in
|
||||
`src/renderer/src/App.tsx` matches most actions via `keybindingMatchesAction`
|
||||
and runs their effects inline.
|
||||
|
||||
## Approach (chosen): synthetic input through the existing matchers
|
||||
|
||||
Detect the double-tap with a small shared state machine, then represent the
|
||||
completed gesture as a **synthetic shortcut input** carrying a
|
||||
`doubleTapModifier` marker and run that input through the *existing* dispatch
|
||||
chains in both layers. The matcher is extended so a `DoubleTap+<Mod>` binding
|
||||
matches only that synthetic input (and never a normal keydown, and vice-versa).
|
||||
|
||||
Because the existing dispatch chains already call
|
||||
`keybindingMatchesAction(actionId, input, …)`, every action that is already
|
||||
wired in those chains gains double-tap support automatically — no per-action
|
||||
dispatch table to build or keep in sync.
|
||||
|
||||
Rejected alternatives:
|
||||
|
||||
- **Per-action dispatch registry** — detector resolves action ids and calls a
|
||||
new `dispatchActionById()` implemented per action. Avoids refactoring the
|
||||
renderer handler but duplicates action effects that already live there, drifts
|
||||
over time, and only supports actions we explicitly wire — not "any action".
|
||||
- **Re-dispatch a synthetic DOM `KeyboardEvent`** — a double-tap can't be
|
||||
expressed as a standard key event without a key, and re-dispatching risks
|
||||
event loops.
|
||||
|
||||
## Components
|
||||
|
||||
### 1. Binding grammar — `src/shared/keybindings.ts`
|
||||
|
||||
- New canonical form `DoubleTap+<Mod>` where `<Mod>` is one of `Shift`, `Mod`,
|
||||
`Cmd`, `Ctrl`, `Alt`. `Mod` resolves to Cmd on macOS and Ctrl on
|
||||
Windows/Linux, identical to normal bindings.
|
||||
- `ParsedKeybinding` gains `doubleTapModifier?: ModifierToken` and permits an
|
||||
empty `key` (only when `doubleTapModifier` is set).
|
||||
- `parseKeybinding` recognizes a leading `DoubleTap` token followed by exactly
|
||||
one modifier token and **no** key token. Anything else with `DoubleTap` is
|
||||
invalid.
|
||||
- `canonicalizeParsedKeybinding` emits `DoubleTap+<Mod>` (modifier in the same
|
||||
canonical position rules as today).
|
||||
- `normalizeKeybindingWithOptions` accepts a well-formed double-tap binding and
|
||||
rejects malformed ones with clear errors:
|
||||
- `DoubleTap` + a key (e.g. `DoubleTap+Shift+P`) → invalid.
|
||||
- `DoubleTap` + two modifiers (e.g. `DoubleTap+Shift+Alt`) → invalid.
|
||||
- `DoubleTap+Mod+Cmd` (both forms) → reuse the existing "Mod or
|
||||
platform-specific, not both" error.
|
||||
- bare `DoubleTap` with no modifier → invalid.
|
||||
- `formatKeybinding` returns the modifier glyph **twice**: macOS `['⇧','⇧']`,
|
||||
Windows/Linux `['Shift','Shift']`.
|
||||
- `ShortcutKeyCombo` renders the two chips. Double-tap is special-cased so the
|
||||
non-Mac separator reads "Shift Shift" (space), not "Shift+Shift". A
|
||||
"Double-tap Shift" tooltip clarifies the gesture.
|
||||
|
||||
### 2. Detector — new module `src/shared/modifier-double-tap-detector.ts`
|
||||
|
||||
A pure, dependency-free state machine. Timestamps are **injected** by the caller
|
||||
so it is deterministic and unit-testable.
|
||||
|
||||
```
|
||||
class ModifierDoubleTapDetector {
|
||||
// event: { type: 'keyDown' | 'keyUp', modifier: ModifierToken | null,
|
||||
// isModifierOnly: boolean, isAutoRepeat: boolean }
|
||||
process(event, timestampMs): DetectedDoubleTap | null
|
||||
reset(): void
|
||||
}
|
||||
```
|
||||
|
||||
State machine:
|
||||
|
||||
1. **idle** → on a modifier-only `keyDown` of M that is not autorepeat: remember
|
||||
M, wait for its release.
|
||||
2. **down1** → on `keyUp` of M (clean, no other key seen): record release time,
|
||||
move to **armed(M)** with deadline `releaseTime + WINDOW_MS`.
|
||||
3. **armed(M)** → on `keyDown` of the same M within the deadline, with no other
|
||||
modifier held and no intervening non-modifier key: **emit** a double-tap of M
|
||||
and reset.
|
||||
|
||||
Any of these reset to idle: a non-modifier key event at any point, a different
|
||||
or additional modifier, autorepeat-hold of the modifier, exceeding the window,
|
||||
or an explicit `reset()` (e.g. on window blur / focus change).
|
||||
|
||||
`WINDOW_MS = 300` (internal constant; not user-configurable). A helper derives
|
||||
`(modifier, isModifierOnly)` from an event's `code`/`key`.
|
||||
|
||||
### 3. Matcher extension — `src/shared/keybindings.ts`
|
||||
|
||||
- `KeybindingInput` gains `doubleTapModifier?: ModifierToken`.
|
||||
- `keybindingMatchesInput`: when the parsed binding is a double-tap binding,
|
||||
match iff `input.doubleTapModifier` equals the binding's modifier, resolved per
|
||||
platform (`Mod` → meta on macOS, control elsewhere). A double-tap binding never
|
||||
matches a normal keydown (no `doubleTapModifier`), and a normal binding never
|
||||
matches a synthetic double-tap input.
|
||||
- No change to `keybindingMatchesAction` — it already delegates to
|
||||
`keybindingMatchesInput`, so any action becomes double-tap-capable for free.
|
||||
|
||||
### 4. Dispatch wiring — both layers
|
||||
|
||||
- **Main** (`src/main/window/createMainWindow.ts`): instantiate a
|
||||
`ModifierDoubleTapDetector` per window. In `before-input-event`, feed every
|
||||
`keyDown`/`keyUp` to the detector (it only consumes bare-modifier events). On
|
||||
emit, build the synthetic input `{ doubleTapModifier: M }`, run the existing
|
||||
`resolveWindowShortcutAction(syntheticInput, platform, keybindings,
|
||||
terminalShortcutContext)`, and if an allowlisted action resolves, dispatch via
|
||||
the current IPC + `preventDefault()` path. `resolveWindowShortcutAction` needs
|
||||
no per-action change; the implicit numeric-index shortcuts are guarded on
|
||||
`input.key`, which is undefined for a double-tap input, so they cannot match.
|
||||
Only the emitting second-keydown event is `preventDefault()`-ed — never the
|
||||
first tap's down/up (those bare modifiers are harmless and the keyup is needed
|
||||
by the detector).
|
||||
- **Renderer** (`src/renderer/src/App.tsx`): extract the body of the window
|
||||
`onKeyDown` handler into `dispatchShortcutInput(input: ShortcutDispatchInput)`,
|
||||
where `ShortcutDispatchInput` exposes the modifier/key fields plus
|
||||
`doubleTapModifier?`, a `preventDefault()` (no-op for synthetic input),
|
||||
`defaultPrevented`, and the focus/target context. The real listener wraps the
|
||||
`KeyboardEvent`; a renderer `ModifierDoubleTapDetector` (fed by both a keydown
|
||||
and a **new** keyup window listener) produces a synthetic input on emit with
|
||||
`context` derived from `document.activeElement`, and calls
|
||||
`dispatchShortcutInput`.
|
||||
|
||||
#### No double-fire between layers
|
||||
|
||||
This reuses the exact disambiguation normal shortcuts already rely on:
|
||||
|
||||
- For an **allowlisted** action, main detects the double-tap on the second
|
||||
modifier keydown, resolves it, and calls `preventDefault()`. That suppresses
|
||||
the corresponding renderer DOM keydown, so the renderer detector never
|
||||
completes its second tap → it does not fire. (The renderer detector may have
|
||||
observed the first tap's down/up. It has no timer: the second-press window is
|
||||
enforced by comparing the next keydown's timestamp against a deadline. The
|
||||
suppressed second keydown never arrives, but its keyup still does — a keyup of
|
||||
the armed modifier with no intervening second keydown clears the armed state,
|
||||
so a later lone press of the same modifier cannot phantom-complete the gesture.)
|
||||
- For a **non-allowlisted** action, main's detector still emits but
|
||||
`resolveWindowShortcutAction` returns `null`, so main does not call
|
||||
`preventDefault()`. The second-keydown DOM event reaches the renderer, whose
|
||||
detector completes and fires via `dispatchShortcutInput`.
|
||||
|
||||
### 5. Recorder UX — `ShortcutBindingRow.tsx` + `ShortcutsPane.tsx`
|
||||
|
||||
The recorder currently captures on the first keydown, which makes a bare
|
||||
modifier error with "Press a key, not only a modifier." Change the row so that
|
||||
while recording it runs a `ModifierDoubleTapDetector` fed by the row button's
|
||||
keydown **and keyup** (the button holds focus during recording, so it receives
|
||||
both):
|
||||
|
||||
- A bare-modifier keydown no longer captures immediately — the detector observes
|
||||
it.
|
||||
- A non-modifier keydown (with or without modifiers) captures a normal binding,
|
||||
exactly as today.
|
||||
- A completed double-tap captures `DoubleTap+<Mod>`: the row passes
|
||||
`{ doubleTapModifier: M }` into the capture path, and
|
||||
`keybindingFromInputWithOptions` short-circuits to build `DoubleTap+<Mod>`
|
||||
(mapping meta → `Mod` on macOS, etc.) and normalizes it.
|
||||
- A single lone modifier tap that never completes is ignored — the recorder
|
||||
keeps listening.
|
||||
|
||||
Helper text while recording: *"Press a shortcut, or double-tap a modifier (e.g.
|
||||
⇧⇧)."* Esc still cancels. The detector is reset when recording stops or the row
|
||||
loses focus.
|
||||
|
||||
### 6. Conflicts & terminal policy
|
||||
|
||||
`DoubleTap+Shift` is a canonical binding string, so `findKeybindingConflicts`
|
||||
compares it like any other binding — two actions sharing a double-tap surface a
|
||||
conflict in the UI. Terminal-policy gating (`keybindingIsActiveInContext`,
|
||||
orca-first / terminal-first) applies unchanged. Note that a bare modifier press
|
||||
emits no terminal bytes, so detecting a double-tap never steals readline input;
|
||||
policy is still honored for consistency.
|
||||
|
||||
## Data flow
|
||||
|
||||
- **Record:** row keydown/keyup → row detector → `{ doubleTapModifier: M }` →
|
||||
`keybindingFromInputForAction` → `DoubleTap+<Mod>` → stored as
|
||||
`["DoubleTap+Shift"]` in `~/.orca/keybindings.json`.
|
||||
- **Runtime:** physical modifier taps → main + renderer detectors → synthetic
|
||||
`{ doubleTapModifier: M }` → existing matchers → action dispatched (main IPC
|
||||
for allowlisted actions, renderer inline for the rest).
|
||||
|
||||
## Behavioral decisions
|
||||
|
||||
- **Trigger edge:** fire on the **second modifier keydown** (snappy), not the
|
||||
second keyup.
|
||||
- **Window:** `WINDOW_MS = 300`, internal constant, not user-configurable.
|
||||
- **Modifiers supported:** Shift, Cmd/Ctrl (`Mod`), Alt — any modifier, recorded
|
||||
as the platform-appropriate token following the existing capture convention.
|
||||
|
||||
## Testing
|
||||
|
||||
- New `src/shared/modifier-double-tap-detector.test.ts`: completion within
|
||||
window; timeout past window; reset on intervening non-modifier key; reset on
|
||||
different/extra modifier; autorepeat-hold is not a tap; wrong-modifier second
|
||||
tap; `reset()` clears state.
|
||||
- `src/shared/keybindings.test.ts` additions: parse / normalize / canonicalize /
|
||||
format for `DoubleTap+*` (incl. malformed-input rejection); platform token
|
||||
mapping (`DoubleTap+Mod` → Cmd on macOS, Ctrl elsewhere);
|
||||
`keybindingMatchesInput` with a synthetic `doubleTapModifier` input (positive
|
||||
and cross-type negatives); conflict detection across two double-tap bindings.
|
||||
- Manual: record `DoubleTap+Shift` on "Go to File"; confirm it fires globally
|
||||
including with a browser guest and a focused terminal; confirm normal Shift+key
|
||||
typing is unaffected; confirm chips and tokens are correct on macOS and
|
||||
Windows/Linux.
|
||||
|
||||
## Risks & edge cases
|
||||
|
||||
- **Accidental triggers during fast typing** — mitigated by requiring a clean
|
||||
down→up→down of the same modifier with no other key, inside a 300ms window.
|
||||
- **macOS Sticky Keys (press Shift 5×)** — unaffected; the gesture is two taps
|
||||
within a tight window.
|
||||
- **Double-fire main vs renderer** — resolved by the
|
||||
`preventDefault`-on-emit mechanism described in §4.
|
||||
- **Focus/window changes mid-sequence** — both detectors reset on blur / focus
|
||||
change (hook into the existing recorder/terminal focus reset paths in the main
|
||||
process and a window blur listener in the renderer).
|
||||
|
|
@ -414,6 +414,14 @@ describe('setupGuestShortcutForwarding', () => {
|
|||
return preventDefault
|
||||
}
|
||||
|
||||
function triggerGuestBlur(): void {
|
||||
const handler = guestOnMock.mock.calls.find((call) => call[0] === 'blur')?.[1] as
|
||||
| (() => void)
|
||||
| undefined
|
||||
expect(handler).toBeTypeOf('function')
|
||||
handler!()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
rendererSendMock = vi.fn()
|
||||
guestOnMock = vi.fn()
|
||||
|
|
@ -552,4 +560,85 @@ describe('setupGuestShortcutForwarding', () => {
|
|||
expect(customPreventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(rendererSendMock).toHaveBeenCalledWith('ui:zoomBrowserPage', 'in')
|
||||
})
|
||||
|
||||
it('forwards double-tap window shortcuts from focused guest pages', () => {
|
||||
setupGuestShortcutForwarding({
|
||||
browserTabId,
|
||||
guest: makeGuest(),
|
||||
resolveRenderer: () => makeRenderer(),
|
||||
getKeybindings: () => ({
|
||||
'worktree.quickOpen': ['DoubleTap+Shift']
|
||||
})
|
||||
})
|
||||
|
||||
const modifierInput = {
|
||||
code: 'ShiftLeft',
|
||||
key: 'Shift',
|
||||
shift: true,
|
||||
meta: false,
|
||||
control: false,
|
||||
alt: false
|
||||
}
|
||||
const firstDownPreventDefault = triggerBeforeInput(modifierInput)
|
||||
const firstUpPreventDefault = triggerBeforeInput({ ...modifierInput, type: 'keyUp' })
|
||||
const secondDownPreventDefault = triggerBeforeInput(modifierInput)
|
||||
|
||||
expect(firstDownPreventDefault).not.toHaveBeenCalled()
|
||||
expect(firstUpPreventDefault).not.toHaveBeenCalled()
|
||||
expect(secondDownPreventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(rendererSendMock).toHaveBeenCalledWith('ui:openQuickOpen')
|
||||
})
|
||||
|
||||
it('forwards double-tap tab shortcuts from focused guest pages', () => {
|
||||
setupGuestShortcutForwarding({
|
||||
browserTabId,
|
||||
guest: makeGuest(),
|
||||
resolveRenderer: () => makeRenderer(),
|
||||
getKeybindings: () => ({
|
||||
'tab.newBrowser': ['DoubleTap+Shift']
|
||||
})
|
||||
})
|
||||
|
||||
const modifierInput = {
|
||||
code: 'ShiftLeft',
|
||||
key: 'Shift',
|
||||
shift: true,
|
||||
meta: false,
|
||||
control: false,
|
||||
alt: false
|
||||
}
|
||||
triggerBeforeInput(modifierInput)
|
||||
triggerBeforeInput({ ...modifierInput, type: 'keyUp' })
|
||||
const secondDownPreventDefault = triggerBeforeInput(modifierInput)
|
||||
|
||||
expect(secondDownPreventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(rendererSendMock).toHaveBeenCalledWith('ui:newBrowserTab')
|
||||
})
|
||||
|
||||
it('resets guest double-tap detection on blur', () => {
|
||||
setupGuestShortcutForwarding({
|
||||
browserTabId,
|
||||
guest: makeGuest(),
|
||||
resolveRenderer: () => makeRenderer(),
|
||||
getKeybindings: () => ({
|
||||
'worktree.quickOpen': ['DoubleTap+Shift']
|
||||
})
|
||||
})
|
||||
|
||||
const modifierInput = {
|
||||
code: 'ShiftLeft',
|
||||
key: 'Shift',
|
||||
shift: true,
|
||||
meta: false,
|
||||
control: false,
|
||||
alt: false
|
||||
}
|
||||
triggerBeforeInput(modifierInput)
|
||||
triggerBeforeInput({ ...modifierInput, type: 'keyUp' })
|
||||
triggerGuestBlur()
|
||||
const nextDownPreventDefault = triggerBeforeInput(modifierInput)
|
||||
|
||||
expect(nextDownPreventDefault).not.toHaveBeenCalled()
|
||||
expect(rendererSendMock).not.toHaveBeenCalledWith('ui:openQuickOpen')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -11,11 +11,16 @@ import {
|
|||
import {
|
||||
isRecentTabSwitcherCommitRelease,
|
||||
matchesRecentTabSwitcherChord,
|
||||
resolveWindowShortcutAction
|
||||
resolveWindowShortcutAction,
|
||||
type WindowShortcutInput
|
||||
} from '../../shared/window-shortcut-policy'
|
||||
import { readGuestNavigationState } from './browser-guest-navigation-state'
|
||||
import { keybindingMatchesAction, type KeybindingOverrides } from '../../shared/keybindings'
|
||||
import type { BrowserPageZoomDirection } from '../../shared/browser-page-zoom'
|
||||
import {
|
||||
ModifierDoubleTapDetector,
|
||||
toModifierDoubleTapEvent
|
||||
} from '../../shared/modifier-double-tap-detector'
|
||||
|
||||
type ResolveRenderer = (browserTabId: string) => Electron.WebContents | null
|
||||
type ShouldForwardDictationShortcut = () => boolean
|
||||
|
|
@ -263,49 +268,30 @@ export function setupGuestShortcutForwarding(args: {
|
|||
getKeybindings
|
||||
} = args
|
||||
let ctrlTabSwitching = false
|
||||
const handler = (event: Electron.Event, input: Electron.Input): void => {
|
||||
const doubleTapDetector = new ModifierDoubleTapDetector()
|
||||
const resetDoubleTapDetector = (): void => doubleTapDetector.reset()
|
||||
type GuestShortcutInput = WindowShortcutInput & { isAutoRepeat?: boolean }
|
||||
|
||||
const forwardShortcutInput = (
|
||||
event: Electron.Event,
|
||||
input: GuestShortcutInput,
|
||||
action = resolveWindowShortcutAction(input, process.platform, getKeybindings?.())
|
||||
): boolean => {
|
||||
const keybindings = getKeybindings?.()
|
||||
if (
|
||||
input.type === 'keyDown' &&
|
||||
matchesRecentTabSwitcherChord(input, process.platform, keybindings)
|
||||
) {
|
||||
event.preventDefault()
|
||||
ctrlTabSwitching = true
|
||||
const renderer = resolveRenderer(browserTabId)
|
||||
renderer?.send('ui:ctrlTabKeyDown', { shiftKey: input.shift === true })
|
||||
return
|
||||
}
|
||||
|
||||
if (ctrlTabSwitching && isRecentTabSwitcherCommitRelease(input)) {
|
||||
event.preventDefault()
|
||||
ctrlTabSwitching = false
|
||||
const renderer = resolveRenderer(browserTabId)
|
||||
renderer?.send('ui:ctrlTabKeyUp')
|
||||
return
|
||||
}
|
||||
|
||||
if (input.type !== 'keyDown') {
|
||||
return
|
||||
}
|
||||
// Why: resolve the policy action once per keystroke. The history-navigate
|
||||
// chord (Cmd/Ctrl+Alt+Arrow) is the only allowlisted chord that carries
|
||||
// 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, keybindings)
|
||||
if (action?.type === 'zoom') {
|
||||
// Why: browser page zoom must consume repeats and teardown races before
|
||||
// Chromium or the guest page can apply its own shortcut behavior.
|
||||
event.preventDefault()
|
||||
const renderer = resolveRenderer(browserTabId)
|
||||
renderer?.send('ui:zoomBrowserPage', action.direction)
|
||||
return
|
||||
return true
|
||||
}
|
||||
if (input.isAutoRepeat) {
|
||||
if (action?.type === 'dictationKeyDown' && shouldForwardDictationShortcut?.()) {
|
||||
event.preventDefault()
|
||||
return true
|
||||
}
|
||||
return
|
||||
return false
|
||||
}
|
||||
if (action?.type === 'worktreeHistoryNavigate') {
|
||||
// Why: preventDefault unconditionally — if we cannot resolve the
|
||||
|
|
@ -317,14 +303,14 @@ export function setupGuestShortcutForwarding(args: {
|
|||
event.preventDefault()
|
||||
const renderer = resolveRenderer(browserTabId)
|
||||
renderer?.send('ui:worktreeHistoryNavigate', action.direction)
|
||||
return
|
||||
return true
|
||||
}
|
||||
|
||||
if (action?.type === 'toggleFloatingTerminal') {
|
||||
event.preventDefault()
|
||||
const renderer = resolveRenderer(browserTabId)
|
||||
renderer?.send('ui:toggleFloatingTerminal')
|
||||
return
|
||||
return true
|
||||
}
|
||||
|
||||
// Why: Cmd/Ctrl+Alt+[ / ] cycles across every tab type. Handled before
|
||||
|
|
@ -344,14 +330,14 @@ export function setupGuestShortcutForwarding(args: {
|
|||
event.preventDefault()
|
||||
const renderer = resolveRenderer(browserTabId)
|
||||
renderer?.send('ui:switchTabAcrossAllTypes', switchAllTypesDirection)
|
||||
return
|
||||
return true
|
||||
}
|
||||
|
||||
if (keybindingMatchesAction('tab.previousRecent', input, process.platform, keybindings)) {
|
||||
event.preventDefault()
|
||||
const renderer = resolveRenderer(browserTabId)
|
||||
renderer?.send('ui:switchRecentTab')
|
||||
return
|
||||
return true
|
||||
}
|
||||
|
||||
// Why: terminal-only tab switching defaults to Ctrl+PageUp/PageDown on every
|
||||
|
|
@ -370,12 +356,12 @@ export function setupGuestShortcutForwarding(args: {
|
|||
event.preventDefault()
|
||||
const renderer = resolveRenderer(browserTabId)
|
||||
renderer?.send('ui:switchTerminalTab', terminalTabDirection)
|
||||
return
|
||||
return true
|
||||
}
|
||||
|
||||
const renderer = resolveRenderer(browserTabId)
|
||||
if (!renderer) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
if (keybindingMatchesAction('tab.newBrowser', input, process.platform, keybindings)) {
|
||||
renderer.send('ui:newBrowserTab')
|
||||
|
|
@ -456,21 +442,84 @@ export function setupGuestShortcutForwarding(args: {
|
|||
renderer.send('ui:jumpToTabIndex', action.index)
|
||||
} else if (action?.type === 'dictationKeyDown') {
|
||||
if (!shouldForwardDictationShortcut?.()) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
renderer.send('ui:dictationKeyDown')
|
||||
} else {
|
||||
return
|
||||
return false
|
||||
}
|
||||
// Why: preventDefault stops the guest page from also processing the chord
|
||||
// (e.g. Cmd+T opening a browser-internal new-tab page).
|
||||
event.preventDefault()
|
||||
return true
|
||||
}
|
||||
|
||||
const handler = (event: Electron.Event, input: Electron.Input): void => {
|
||||
const keybindings = getKeybindings?.()
|
||||
if (
|
||||
input.type === 'keyDown' &&
|
||||
matchesRecentTabSwitcherChord(input, process.platform, keybindings)
|
||||
) {
|
||||
event.preventDefault()
|
||||
ctrlTabSwitching = true
|
||||
const renderer = resolveRenderer(browserTabId)
|
||||
renderer?.send('ui:ctrlTabKeyDown', { shiftKey: input.shift === true })
|
||||
return
|
||||
}
|
||||
|
||||
if (ctrlTabSwitching && isRecentTabSwitcherCommitRelease(input)) {
|
||||
event.preventDefault()
|
||||
ctrlTabSwitching = false
|
||||
const renderer = resolveRenderer(browserTabId)
|
||||
renderer?.send('ui:ctrlTabKeyUp')
|
||||
return
|
||||
}
|
||||
|
||||
if (input.type === 'keyDown' || input.type === 'keyUp') {
|
||||
const detected = doubleTapDetector.process(
|
||||
toModifierDoubleTapEvent({
|
||||
type: input.type,
|
||||
code: input.code,
|
||||
key: input.key,
|
||||
shift: input.shift,
|
||||
control: input.control,
|
||||
alt: input.alt,
|
||||
meta: input.meta,
|
||||
isAutoRepeat: input.isAutoRepeat
|
||||
}),
|
||||
Date.now()
|
||||
)
|
||||
if (detected) {
|
||||
const doubleTapInput: GuestShortcutInput = { doubleTapModifier: detected.modifier }
|
||||
forwardShortcutInput(
|
||||
event,
|
||||
doubleTapInput,
|
||||
resolveWindowShortcutAction(doubleTapInput, process.platform, keybindings, {
|
||||
context: 'app'
|
||||
})
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (input.type !== 'keyDown') {
|
||||
return
|
||||
}
|
||||
// Why: resolve the policy action once per keystroke. The history-navigate
|
||||
// chord (Cmd/Ctrl+Alt+Arrow) is the only allowlisted chord that carries
|
||||
// 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, keybindings)
|
||||
forwardShortcutInput(event, input, action)
|
||||
}
|
||||
|
||||
guest.on('before-input-event', handler)
|
||||
guest.on('blur', resetDoubleTapDetector)
|
||||
return () => {
|
||||
try {
|
||||
guest.off('before-input-event', handler)
|
||||
guest.off('blur', resetDoubleTapDetector)
|
||||
} catch {
|
||||
// Why: best-effort — guest may already be destroyed during teardown.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -665,6 +665,100 @@ describe('createMainWindow', () => {
|
|||
expect(webContents.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('only intercepts double-tap dictation when enabled toggle mode can handle it', () => {
|
||||
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
|
||||
})
|
||||
|
||||
const voice: { enabled: boolean; sttModel: string; dictationMode: 'toggle' | 'hold' } = {
|
||||
enabled: false,
|
||||
sttModel: '',
|
||||
dictationMode: 'toggle'
|
||||
}
|
||||
createMainWindow(
|
||||
{
|
||||
getUI: () => ({}),
|
||||
getSettings: () => ({ windowBackgroundBlur: false, voice }) as never,
|
||||
updateUI: vi.fn()
|
||||
} as never,
|
||||
{
|
||||
getKeybindings: () => ({ 'voice.dictation': ['DoubleTap+Shift'] })
|
||||
}
|
||||
)
|
||||
|
||||
const triggerDoubleTapShift = (): ReturnType<typeof vi.fn> => {
|
||||
const modifierInput = {
|
||||
code: 'ShiftLeft',
|
||||
key: 'Shift',
|
||||
shift: true,
|
||||
meta: false,
|
||||
control: false,
|
||||
alt: false
|
||||
}
|
||||
windowHandlers['before-input-event'](
|
||||
{ preventDefault: vi.fn() } as never,
|
||||
{ ...modifierInput, type: 'keyDown' } as never
|
||||
)
|
||||
windowHandlers['before-input-event'](
|
||||
{ preventDefault: vi.fn() } as never,
|
||||
{ ...modifierInput, type: 'keyUp' } as never
|
||||
)
|
||||
const preventDefault = vi.fn()
|
||||
windowHandlers['before-input-event'](
|
||||
{ preventDefault } as never,
|
||||
{ ...modifierInput, type: 'keyDown' } as never
|
||||
)
|
||||
windowHandlers['before-input-event'](
|
||||
{ preventDefault: vi.fn() } as never,
|
||||
{ ...modifierInput, type: 'keyUp' } as never
|
||||
)
|
||||
return preventDefault
|
||||
}
|
||||
|
||||
const disabledPreventDefault = triggerDoubleTapShift()
|
||||
expect(disabledPreventDefault).not.toHaveBeenCalled()
|
||||
expect(webContents.send).not.toHaveBeenCalledWith('ui:dictationKeyDown')
|
||||
|
||||
voice.enabled = true
|
||||
voice.sttModel = 'test-model'
|
||||
voice.dictationMode = 'hold'
|
||||
const holdPreventDefault = triggerDoubleTapShift()
|
||||
expect(holdPreventDefault).not.toHaveBeenCalled()
|
||||
expect(webContents.send).not.toHaveBeenCalledWith('ui:dictationKeyDown')
|
||||
|
||||
voice.dictationMode = 'toggle'
|
||||
const togglePreventDefault = triggerDoubleTapShift()
|
||||
expect(togglePreventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(webContents.send).toHaveBeenCalledWith('ui:dictationKeyDown')
|
||||
})
|
||||
|
||||
it('forwards ctrl/cmd+j to the worktree palette toggle event', () => {
|
||||
const windowHandlers: Record<string, (...args: any[]) => void> = {}
|
||||
const webContents = {
|
||||
|
|
@ -792,6 +886,85 @@ describe('createMainWindow', () => {
|
|||
expect(webContents.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows double-tap shortcuts while terminal input is focused with Terminal-first policy', () => {
|
||||
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(
|
||||
{
|
||||
getUI: () => ({}),
|
||||
getSettings: () => ({ terminalShortcutPolicy: 'terminal-first' })
|
||||
} as never,
|
||||
{
|
||||
getKeybindings: () => ({ 'worktree.quickOpen': ['DoubleTap+Shift'] })
|
||||
}
|
||||
)
|
||||
|
||||
const setFocusedListener = vi
|
||||
.mocked(ipcMain.on)
|
||||
.mock.calls.find(([channel]) => channel === 'ui:setTerminalInputFocused')?.[1]
|
||||
expect(setFocusedListener).toBeTypeOf('function')
|
||||
setFocusedListener?.({ sender: webContents } as never, true)
|
||||
|
||||
const modifierInput = {
|
||||
code: 'ShiftLeft',
|
||||
key: 'Shift',
|
||||
shift: true,
|
||||
meta: false,
|
||||
control: false,
|
||||
alt: false
|
||||
}
|
||||
const firstDownPreventDefault = vi.fn()
|
||||
windowHandlers['before-input-event'](
|
||||
{ preventDefault: firstDownPreventDefault } as never,
|
||||
{ ...modifierInput, type: 'keyDown' } as never
|
||||
)
|
||||
const firstUpPreventDefault = vi.fn()
|
||||
windowHandlers['before-input-event'](
|
||||
{ preventDefault: firstUpPreventDefault } as never,
|
||||
{ ...modifierInput, type: 'keyUp' } as never
|
||||
)
|
||||
const secondDownPreventDefault = vi.fn()
|
||||
windowHandlers['before-input-event'](
|
||||
{ preventDefault: secondDownPreventDefault } as never,
|
||||
{ ...modifierInput, type: 'keyDown' } as never
|
||||
)
|
||||
|
||||
expect(firstDownPreventDefault).not.toHaveBeenCalled()
|
||||
expect(firstUpPreventDefault).not.toHaveBeenCalled()
|
||||
expect(secondDownPreventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(webContents.send).toHaveBeenCalledTimes(1)
|
||||
expect(webContents.send).toHaveBeenCalledWith('ui:openQuickOpen')
|
||||
})
|
||||
|
||||
it('notifies before Orca-first captures a risky terminal-focused shortcut', () => {
|
||||
const windowHandlers: Record<string, (...args: any[]) => void> = {}
|
||||
const webContents = {
|
||||
|
|
@ -857,6 +1030,83 @@ describe('createMainWindow', () => {
|
|||
expect(webContents.send).toHaveBeenNthCalledWith(2, 'ui:toggleWorktreePalette')
|
||||
})
|
||||
|
||||
it('notifies before Orca-first captures a terminal-focused double-tap shortcut', () => {
|
||||
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(
|
||||
{
|
||||
getUI: () => ({}),
|
||||
getSettings: () => ({ terminalShortcutPolicy: 'orca-first' })
|
||||
} as never,
|
||||
{
|
||||
getKeybindings: () => ({ 'worktree.quickOpen': ['DoubleTap+Shift'] })
|
||||
}
|
||||
)
|
||||
|
||||
const setFocusedListener = vi
|
||||
.mocked(ipcMain.on)
|
||||
.mock.calls.find(([channel]) => channel === 'ui:setTerminalInputFocused')?.[1]
|
||||
expect(setFocusedListener).toBeTypeOf('function')
|
||||
setFocusedListener?.({ sender: webContents } as never, true)
|
||||
|
||||
const modifierInput = {
|
||||
code: 'ShiftLeft',
|
||||
key: 'Shift',
|
||||
shift: true,
|
||||
meta: false,
|
||||
control: false,
|
||||
alt: false
|
||||
}
|
||||
windowHandlers['before-input-event'](
|
||||
{ preventDefault: vi.fn() } as never,
|
||||
{ ...modifierInput, type: 'keyDown' } as never
|
||||
)
|
||||
windowHandlers['before-input-event'](
|
||||
{ preventDefault: vi.fn() } as never,
|
||||
{ ...modifierInput, type: 'keyUp' } as never
|
||||
)
|
||||
const preventDefault = vi.fn()
|
||||
windowHandlers['before-input-event'](
|
||||
{ preventDefault } as never,
|
||||
{ ...modifierInput, type: 'keyDown' } as never
|
||||
)
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(webContents.send).toHaveBeenNthCalledWith(1, 'ui:terminalShortcutCaptured', {
|
||||
actionId: 'worktree.quickOpen'
|
||||
})
|
||||
expect(webContents.send).toHaveBeenNthCalledWith(2, 'ui:openQuickOpen')
|
||||
})
|
||||
|
||||
it('forwards the configured workspace delete shortcut while terminal input is focused', () => {
|
||||
const windowHandlers: Record<string, (...args: any[]) => void> = {}
|
||||
const webContents = {
|
||||
|
|
|
|||
|
|
@ -15,8 +15,13 @@ import {
|
|||
getWindowShortcutActionId,
|
||||
matchesRecentTabSwitcherChord,
|
||||
resolveWindowShortcutAction,
|
||||
windowShortcutActionCapturesTerminal
|
||||
windowShortcutActionCapturesTerminal,
|
||||
type WindowShortcutAction
|
||||
} from '../../shared/window-shortcut-policy'
|
||||
import {
|
||||
ModifierDoubleTapDetector,
|
||||
toModifierDoubleTapEvent
|
||||
} from '../../shared/modifier-double-tap-detector'
|
||||
import {
|
||||
keybindingMatchesAction,
|
||||
normalizeTerminalShortcutPolicy,
|
||||
|
|
@ -655,6 +660,127 @@ export function createMainWindow(
|
|||
clearRendererRecoveryTimer()
|
||||
})
|
||||
|
||||
const doubleTapDetector = new ModifierDoubleTapDetector()
|
||||
|
||||
// Why: one place maps a resolved window-shortcut action to its IPC/side effect,
|
||||
// reused by the normal keydown path and the double-tap path so they cannot drift.
|
||||
const sendResolvedWindowShortcutAction = (action: WindowShortcutAction): void => {
|
||||
switch (action.type) {
|
||||
// The renderer's DictationController re-checks enabled/sttModel and ignores
|
||||
// hold mode, so this path needs no voice guards.
|
||||
case 'dictationKeyDown':
|
||||
mainWindow.webContents.send('ui:dictationKeyDown')
|
||||
return
|
||||
case 'zoom':
|
||||
mainWindow.webContents.send('terminal:zoom', action.direction)
|
||||
return
|
||||
case 'openSettings':
|
||||
mainWindow.webContents.send('ui:openSettings')
|
||||
return
|
||||
case 'forceReload':
|
||||
opts?.onBeforeReload?.({ ignoreCache: true, webContentsId: mainWindow.webContents.id })
|
||||
mainWindow.webContents.reloadIgnoringCache()
|
||||
return
|
||||
case 'toggleLeftSidebar':
|
||||
mainWindow.webContents.send('ui:toggleLeftSidebar')
|
||||
return
|
||||
case 'toggleRightSidebar':
|
||||
mainWindow.webContents.send('ui:toggleRightSidebar')
|
||||
return
|
||||
case 'toggleWorktreePalette':
|
||||
mainWindow.webContents.send('ui:toggleWorktreePalette')
|
||||
return
|
||||
case 'toggleFloatingTerminal':
|
||||
mainWindow.webContents.send('ui:toggleFloatingTerminal')
|
||||
return
|
||||
case 'openQuickOpen':
|
||||
mainWindow.webContents.send('ui:openQuickOpen')
|
||||
return
|
||||
case 'openNewWorkspace':
|
||||
mainWindow.webContents.send('ui:openNewWorkspace')
|
||||
return
|
||||
case 'deleteCurrentWorkspace':
|
||||
mainWindow.webContents.send('ui:deleteCurrentWorkspace')
|
||||
return
|
||||
case 'openWorkspaceBoard':
|
||||
mainWindow.webContents.send('ui:openWorkspaceBoard')
|
||||
return
|
||||
case 'openTasks':
|
||||
mainWindow.webContents.send('ui:openTasks')
|
||||
return
|
||||
case 'switchRecentTab':
|
||||
mainWindow.webContents.send('ui:switchRecentTab')
|
||||
return
|
||||
case 'jumpToWorktreeIndex':
|
||||
mainWindow.webContents.send('ui:jumpToWorktreeIndex', action.index)
|
||||
return
|
||||
case 'jumpToTabIndex':
|
||||
mainWindow.webContents.send('ui:jumpToTabIndex', action.index)
|
||||
return
|
||||
case 'worktreeHistoryNavigate':
|
||||
mainWindow.webContents.send('ui:worktreeHistoryNavigate', action.direction)
|
||||
}
|
||||
}
|
||||
|
||||
const dispatchResolvedWindowShortcutAction = (
|
||||
event: Electron.Event,
|
||||
action: WindowShortcutAction,
|
||||
options: {
|
||||
isAutoRepeat: boolean
|
||||
focusedShortcutContext: KeybindingMatchOptions
|
||||
}
|
||||
): boolean => {
|
||||
const { focusedShortcutContext, isAutoRepeat } = options
|
||||
if (
|
||||
floatingTerminalInputFocused &&
|
||||
(action.type === 'toggleLeftSidebar' || action.type === 'toggleRightSidebar')
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const capturedTerminalActionId =
|
||||
focusedShortcutContext.context === 'terminal' &&
|
||||
focusedShortcutContext.terminalShortcutPolicy === 'orca-first' &&
|
||||
windowShortcutActionCapturesTerminal(action)
|
||||
? getWindowShortcutActionId(action)
|
||||
: null
|
||||
|
||||
// Why: hold-mode dictation needs renderer keyup events, so the main process
|
||||
// may only consume shortcuts that toggle dictation from a single keydown.
|
||||
if (action.type === 'dictationKeyDown') {
|
||||
const voiceSettings = store?.getSettings().voice
|
||||
if (!voiceSettings?.enabled || !voiceSettings.sttModel) {
|
||||
return false
|
||||
}
|
||||
const dictationMode = voiceSettings.dictationMode ?? 'toggle'
|
||||
if (dictationMode === 'hold') {
|
||||
return false
|
||||
}
|
||||
if (isAutoRepeat) {
|
||||
event.preventDefault()
|
||||
return true
|
||||
}
|
||||
event.preventDefault()
|
||||
if (capturedTerminalActionId) {
|
||||
mainWindow.webContents.send('ui:terminalShortcutCaptured', {
|
||||
actionId: capturedTerminalActionId
|
||||
})
|
||||
}
|
||||
mainWindow.webContents.send('ui:dictationKeyDown')
|
||||
return true
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
if (capturedTerminalActionId) {
|
||||
mainWindow.webContents.send('ui:terminalShortcutCaptured', {
|
||||
actionId: capturedTerminalActionId
|
||||
})
|
||||
}
|
||||
|
||||
sendResolvedWindowShortcutAction(action)
|
||||
return true
|
||||
}
|
||||
|
||||
mainWindow.webContents.on('before-input-event', (event, input) => {
|
||||
if (shortcutRecorderFocused) {
|
||||
return
|
||||
|
|
@ -677,6 +803,51 @@ export function createMainWindow(
|
|||
store?.getSettings().terminalShortcutPolicy
|
||||
)
|
||||
}
|
||||
const appShortcutContext: KeybindingMatchOptions = {
|
||||
context: 'app',
|
||||
terminalShortcutPolicy: terminalShortcutContext.terminalShortcutPolicy
|
||||
}
|
||||
|
||||
// Why: detect double-tap-modifier gestures on the raw key stream. A bare
|
||||
// modifier emits no terminal bytes, so this never steals readline input.
|
||||
if (input.type === 'keyDown' || input.type === 'keyUp') {
|
||||
const detected = doubleTapDetector.process(
|
||||
toModifierDoubleTapEvent({
|
||||
type: input.type,
|
||||
code: input.code,
|
||||
key: input.key,
|
||||
shift: input.shift,
|
||||
control: input.control,
|
||||
alt: input.alt,
|
||||
meta: input.meta,
|
||||
isAutoRepeat: input.isAutoRepeat
|
||||
}),
|
||||
Date.now()
|
||||
)
|
||||
if (detected) {
|
||||
const doubleTapAction = resolveWindowShortcutAction(
|
||||
{ type: 'keyDown', doubleTapModifier: detected.modifier },
|
||||
process.platform,
|
||||
keybindings,
|
||||
appShortcutContext
|
||||
)
|
||||
if (
|
||||
doubleTapAction &&
|
||||
dispatchResolvedWindowShortcutAction(event, doubleTapAction, {
|
||||
isAutoRepeat: false,
|
||||
focusedShortcutContext: terminalShortcutContext
|
||||
})
|
||||
) {
|
||||
// Only preventDefault the emitting keydown — never the first tap's
|
||||
// down/up. This suppresses the renderer DOM keydown so the renderer
|
||||
// detector cannot also fire for the same gesture.
|
||||
return
|
||||
}
|
||||
// No allowlisted action: let the keydown reach the renderer, whose
|
||||
// detector completes and dispatches inline.
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
input.type === 'keyDown' &&
|
||||
matchesRecentTabSwitcherChord(input, process.platform, keybindings, terminalShortcutContext)
|
||||
|
|
@ -715,156 +886,20 @@ export function createMainWindow(
|
|||
return
|
||||
}
|
||||
|
||||
// Why: keep global app routing for non-terminal actions, but let floating
|
||||
// xterm own shell control chars that overlap sidebar chrome shortcuts.
|
||||
if (
|
||||
floatingTerminalInputFocused &&
|
||||
(action.type === 'toggleLeftSidebar' || action.type === 'toggleRightSidebar')
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (input.type !== 'keyDown') {
|
||||
return
|
||||
}
|
||||
|
||||
const capturedTerminalActionId =
|
||||
terminalShortcutContext.context === 'terminal' &&
|
||||
terminalShortcutContext.terminalShortcutPolicy === 'orca-first' &&
|
||||
windowShortcutActionCapturesTerminal(action)
|
||||
? getWindowShortcutActionId(action)
|
||||
: null
|
||||
|
||||
// Why: in hold mode, Cmd+E must NOT be intercepted here. Calling
|
||||
// preventDefault() in before-input-event suppresses ALL subsequent DOM
|
||||
// events for the key combo — including the keyUp the renderer needs to
|
||||
// detect release. By letting the event through, the renderer's
|
||||
// capture-phase DOM listeners handle both keydown and keyup normally.
|
||||
// Toggle mode still uses the IPC path since it doesn't need keyUp.
|
||||
if (action.type === 'dictationKeyDown') {
|
||||
const voiceSettings = store?.getSettings().voice
|
||||
if (!voiceSettings?.enabled || !voiceSettings.sttModel) {
|
||||
return
|
||||
}
|
||||
const dictationMode = voiceSettings.dictationMode ?? 'toggle'
|
||||
if (dictationMode === 'hold') {
|
||||
return
|
||||
}
|
||||
if (input.isAutoRepeat) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
if (capturedTerminalActionId) {
|
||||
mainWindow.webContents.send('ui:terminalShortcutCaptured', {
|
||||
actionId: capturedTerminalActionId
|
||||
})
|
||||
}
|
||||
mainWindow.webContents.send('ui:dictationKeyDown')
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
if (capturedTerminalActionId) {
|
||||
mainWindow.webContents.send('ui:terminalShortcutCaptured', {
|
||||
actionId: capturedTerminalActionId
|
||||
})
|
||||
}
|
||||
|
||||
if (action.type === 'zoom') {
|
||||
mainWindow.webContents.send('terminal:zoom', action.direction)
|
||||
return
|
||||
}
|
||||
|
||||
if (action.type === 'openSettings') {
|
||||
mainWindow.webContents.send('ui:openSettings')
|
||||
return
|
||||
}
|
||||
|
||||
if (action.type === 'forceReload') {
|
||||
opts?.onBeforeReload?.({
|
||||
ignoreCache: true,
|
||||
webContentsId: mainWindow.webContents.id
|
||||
})
|
||||
mainWindow.webContents.reloadIgnoringCache()
|
||||
return
|
||||
}
|
||||
|
||||
if (action.type === 'toggleLeftSidebar') {
|
||||
mainWindow.webContents.send('ui:toggleLeftSidebar')
|
||||
return
|
||||
}
|
||||
|
||||
if (action.type === 'toggleRightSidebar') {
|
||||
mainWindow.webContents.send('ui:toggleRightSidebar')
|
||||
return
|
||||
}
|
||||
|
||||
if (action.type === 'toggleWorktreePalette') {
|
||||
// Why: embedded browser guests can keep keyboard focus inside Chromium's
|
||||
// guest webContents, which bypasses the renderer's window-level keydown
|
||||
// listener. Forward the worktree-switch shortcut through the main window
|
||||
// so Cmd+J (macOS) or Ctrl+Shift+J (Win/Linux) works consistently from browser tabs too.
|
||||
mainWindow.webContents.send('ui:toggleWorktreePalette')
|
||||
return
|
||||
}
|
||||
|
||||
if (action.type === 'toggleFloatingTerminal') {
|
||||
mainWindow.webContents.send('ui:toggleFloatingTerminal')
|
||||
return
|
||||
}
|
||||
|
||||
if (action.type === 'openQuickOpen') {
|
||||
mainWindow.webContents.send('ui:openQuickOpen')
|
||||
return
|
||||
}
|
||||
|
||||
if (action.type === 'openNewWorkspace') {
|
||||
// Why: routed through the main process so focus contexts that bypass
|
||||
// the renderer's window-level keydown (contentEditable markdown editor,
|
||||
// browser-guest webContents) still reach the new-workspace composer.
|
||||
mainWindow.webContents.send('ui:openNewWorkspace')
|
||||
return
|
||||
}
|
||||
|
||||
if (action.type === 'deleteCurrentWorkspace') {
|
||||
mainWindow.webContents.send('ui:deleteCurrentWorkspace')
|
||||
return
|
||||
}
|
||||
|
||||
if (action.type === 'openWorkspaceBoard') {
|
||||
mainWindow.webContents.send('ui:openWorkspaceBoard')
|
||||
return
|
||||
}
|
||||
|
||||
if (action.type === 'openTasks') {
|
||||
mainWindow.webContents.send('ui:openTasks')
|
||||
return
|
||||
}
|
||||
|
||||
if (action.type === 'switchRecentTab') {
|
||||
mainWindow.webContents.send('ui:switchRecentTab')
|
||||
return
|
||||
}
|
||||
|
||||
if (action.type === 'jumpToWorktreeIndex') {
|
||||
mainWindow.webContents.send('ui:jumpToWorktreeIndex', action.index)
|
||||
return
|
||||
}
|
||||
|
||||
if (action.type === 'jumpToTabIndex') {
|
||||
mainWindow.webContents.send('ui:jumpToTabIndex', action.index)
|
||||
return
|
||||
}
|
||||
|
||||
if (action.type === 'worktreeHistoryNavigate') {
|
||||
// Why: routed through main so the chord reaches the renderer even when
|
||||
// a terminal (xterm.js) or a browser guest has focus — both surfaces
|
||||
// otherwise absorb Arrow keys before the renderer's window listener.
|
||||
mainWindow.webContents.send('ui:worktreeHistoryNavigate', action.direction)
|
||||
}
|
||||
dispatchResolvedWindowShortcutAction(event, action, {
|
||||
isAutoRepeat: Boolean(input.isAutoRepeat),
|
||||
focusedShortcutContext: terminalShortcutContext
|
||||
})
|
||||
})
|
||||
|
||||
// Why: a mid-gesture focus loss must not leave the detector armed so the next
|
||||
// unrelated modifier press completes a phantom double-tap.
|
||||
mainWindow.on('blur', () => doubleTapDetector.reset())
|
||||
|
||||
mainWindow.webContents.on('zoom-changed', (event, zoomDirection) => {
|
||||
// Why: Some keyboard layouts/platforms consume Ctrl/Cmd+Minus before
|
||||
// before-input-event fires, but still emit Electron's zoom command. Keep
|
||||
|
|
|
|||
|
|
@ -136,8 +136,13 @@ import {
|
|||
import {
|
||||
keybindingMatchesAction,
|
||||
type KeybindingActionId,
|
||||
type KeybindingContext
|
||||
type KeybindingContext,
|
||||
type PhysicalModifierToken
|
||||
} from '../../shared/keybindings'
|
||||
import {
|
||||
ModifierDoubleTapDetector,
|
||||
toModifierDoubleTapEvent
|
||||
} from '../../shared/modifier-double-tap-detector'
|
||||
import { isGitRepoKind } from '../../shared/repo-kind'
|
||||
import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut-capture-notification'
|
||||
import { resolveMountedLazyModalIds, type LazyModalId } from './lazy-modal-mount-state'
|
||||
|
|
@ -153,6 +158,22 @@ function getKeybindingContext(target: EventTarget | null): KeybindingContext {
|
|||
: 'app'
|
||||
}
|
||||
|
||||
// Abstraction over a real KeyboardEvent and a synthetic double-tap gesture so a
|
||||
// single dispatch path serves both. KeybindingInput-compatible (key/code +
|
||||
// modifier flags) so it flows straight into keybindingMatchesAction.
|
||||
type ShortcutDispatchInput = {
|
||||
key?: string
|
||||
code?: string
|
||||
altKey?: boolean
|
||||
metaKey?: boolean
|
||||
ctrlKey?: boolean
|
||||
shiftKey?: boolean
|
||||
doubleTapModifier?: PhysicalModifierToken
|
||||
target: EventTarget | null
|
||||
defaultPrevented: boolean
|
||||
preventDefault: () => void
|
||||
}
|
||||
|
||||
// Why: 'hidden' titleBarStyle on Windows removes the native OS title bar,
|
||||
// so we render our own minimize/maximize/close buttons. These SVG icons match
|
||||
// the Fluent/Win11 style: thin 10×10 paths on a 40×30 hit area.
|
||||
|
|
@ -1347,26 +1368,25 @@ function App(): React.JSX.Element {
|
|||
}
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent): void => {
|
||||
if (e.repeat) {
|
||||
return
|
||||
}
|
||||
const doubleTapDetector = new ModifierDoubleTapDetector()
|
||||
|
||||
const dispatchShortcutInput = (input: ShortcutDispatchInput): void => {
|
||||
// Why: child-component handlers (e.g. terminal search Cmd+G / Cmd+Shift+G)
|
||||
// register on the same window capture phase and fire first. If they already
|
||||
// called preventDefault, this handler must not also act on the event —
|
||||
// otherwise both actions execute (e.g. search navigation AND sidebar open).
|
||||
if (e.defaultPrevented) {
|
||||
if (input.defaultPrevented) {
|
||||
return
|
||||
}
|
||||
// Why: the Settings recorder intentionally captures existing app
|
||||
// shortcuts, so global handlers must not fire while its button has focus.
|
||||
if (
|
||||
e.target instanceof Element &&
|
||||
e.target.closest('[data-shortcut-recorder-active]') !== null
|
||||
input.target instanceof Element &&
|
||||
input.target.closest('[data-shortcut-recorder-active]') !== null
|
||||
) {
|
||||
return
|
||||
}
|
||||
const context = getKeybindingContext(e.target)
|
||||
const context = getKeybindingContext(input.target)
|
||||
|
||||
// Note: some app-level shortcuts are also intercepted via
|
||||
// before-input-event in createMainWindow.ts so they still work when a
|
||||
|
|
@ -1374,7 +1394,7 @@ function App(): React.JSX.Element {
|
|||
// local-focus cases and to preserve the same guards in one place.
|
||||
|
||||
const matchShortcut = (actionId: KeybindingActionId): boolean =>
|
||||
keybindingMatchesAction(actionId, e, shortcutPlatform, keybindings, {
|
||||
keybindingMatchesAction(actionId, input, shortcutPlatform, keybindings, {
|
||||
context,
|
||||
terminalShortcutPolicy: settings?.terminalShortcutPolicy
|
||||
})
|
||||
|
|
@ -1407,7 +1427,7 @@ function App(): React.JSX.Element {
|
|||
? selectedExplorerFolderRelativePath(document.activeElement)
|
||||
: null
|
||||
if (selectedFolderRelativePath !== null && activeWorktreeId) {
|
||||
e.preventDefault()
|
||||
input.preventDefault()
|
||||
notifyTerminalCapture('sidebar.search.toggle')
|
||||
actions.showRightSidebarSearch({
|
||||
includePattern: folderRelativePathToIncludeGlob(selectedFolderRelativePath)
|
||||
|
|
@ -1417,7 +1437,7 @@ function App(): React.JSX.Element {
|
|||
|
||||
const selectedText = getSelectedTextForFileSearch()
|
||||
if (selectedText) {
|
||||
e.preventDefault()
|
||||
input.preventDefault()
|
||||
notifyTerminalCapture('sidebar.search.toggle')
|
||||
openSearchSidebar(selectedText)
|
||||
return
|
||||
|
|
@ -1427,7 +1447,7 @@ function App(): React.JSX.Element {
|
|||
// Why: an empty floating workspace has no tab to close; Cmd/Ctrl+W
|
||||
// should hide that transient overlay before underlying app surfaces act.
|
||||
if (
|
||||
keybindingMatchesAction('tab.close', e, shortcutPlatform, keybindings, {
|
||||
keybindingMatchesAction('tab.close', input, shortcutPlatform, keybindings, {
|
||||
context: 'app'
|
||||
}) &&
|
||||
shouldMinimizeFloatingWorkspacePanelOnCloseShortcut({
|
||||
|
|
@ -1435,7 +1455,7 @@ function App(): React.JSX.Element {
|
|||
floatingVisibleTabCount
|
||||
})
|
||||
) {
|
||||
e.preventDefault()
|
||||
input.preventDefault()
|
||||
setFloatingTerminalOpenWithFocus(false)
|
||||
return
|
||||
}
|
||||
|
|
@ -1446,14 +1466,14 @@ function App(): React.JSX.Element {
|
|||
// Cmd+B for the markdown editor (see createMainWindow.ts +
|
||||
// docs/markdown-cmd-b-bold-design.md), but this renderer-side fallback
|
||||
// still covers the blur→press IPC race and any non-carved editable surface.
|
||||
if (isEditableTarget(e.target)) {
|
||||
if (isEditableTarget(input.target)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Why: xterm's helper textarea is intentionally not a generic editable
|
||||
// target, but floating-terminal SSH/tmux control chords must still reach
|
||||
// the terminal instead of app-level chrome shortcuts.
|
||||
if (isFloatingWorkspaceTerminalInputTarget(e.target)) {
|
||||
if (isFloatingWorkspaceTerminalInputTarget(input.target)) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -1466,7 +1486,7 @@ function App(): React.JSX.Element {
|
|||
if (creationLayoutActive || !shouldShowWorktreeHistoryControls(activeView)) {
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
input.preventDefault()
|
||||
const store = useAppStore.getState()
|
||||
if (matchShortcut('worktree.history.back')) {
|
||||
store.goBackWorktree()
|
||||
|
|
@ -1484,7 +1504,7 @@ function App(): React.JSX.Element {
|
|||
const floatingWorkspaceFocused = isFloatingWorkspacePanelFocused()
|
||||
if (floatingWorkspaceFocused) {
|
||||
if (
|
||||
isFloatingWorkspacePanelShortcut(e, shortcutPlatform, null, keybindings, {
|
||||
isFloatingWorkspacePanelShortcut(input, shortcutPlatform, null, keybindings, {
|
||||
context,
|
||||
terminalShortcutPolicy: settings?.terminalShortcutPolicy
|
||||
})
|
||||
|
|
@ -1495,7 +1515,7 @@ function App(): React.JSX.Element {
|
|||
|
||||
// Cmd/Ctrl+B — toggle left sidebar
|
||||
if (matchShortcut('sidebar.left.toggle')) {
|
||||
e.preventDefault()
|
||||
input.preventDefault()
|
||||
notifyTerminalCapture('sidebar.left.toggle')
|
||||
actions.toggleSidebar()
|
||||
return
|
||||
|
|
@ -1508,7 +1528,7 @@ function App(): React.JSX.Element {
|
|||
if (workspaceChromeActive && !floatingWorkspaceFocused && matchShortcut('tab.rename')) {
|
||||
const store = useAppStore.getState()
|
||||
if (store.activeTabType === 'terminal' && store.activeTabId) {
|
||||
e.preventDefault()
|
||||
input.preventDefault()
|
||||
notifyTerminalCapture('tab.rename')
|
||||
store.setRenamingTabId(store.activeTabId)
|
||||
return
|
||||
|
|
@ -1524,7 +1544,7 @@ function App(): React.JSX.Element {
|
|||
matchShortcut('workspace.rename') &&
|
||||
activeWorktreeId
|
||||
) {
|
||||
e.preventDefault()
|
||||
input.preventDefault()
|
||||
notifyTerminalCapture('workspace.rename')
|
||||
const store = useAppStore.getState()
|
||||
store.setSidebarOpen(true)
|
||||
|
|
@ -1533,7 +1553,7 @@ function App(): React.JSX.Element {
|
|||
}
|
||||
|
||||
if (matchShortcut('workspace.openBoard') && activeView !== 'settings') {
|
||||
e.preventDefault()
|
||||
input.preventDefault()
|
||||
notifyTerminalCapture('workspace.openBoard')
|
||||
const store = useAppStore.getState()
|
||||
store.setSidebarOpen(true)
|
||||
|
|
@ -1552,7 +1572,7 @@ function App(): React.JSX.Element {
|
|||
if (matchShortcut('view.tasks') && activeView !== 'settings') {
|
||||
const store = useAppStore.getState()
|
||||
if (store.repos.some((repo) => isGitRepoKind(repo))) {
|
||||
e.preventDefault()
|
||||
input.preventDefault()
|
||||
notifyTerminalCapture('view.tasks')
|
||||
store.openTaskPage()
|
||||
}
|
||||
|
|
@ -1565,7 +1585,7 @@ function App(): React.JSX.Element {
|
|||
|
||||
// Cmd/Ctrl+L — toggle right sidebar
|
||||
if (matchShortcut('sidebar.right.toggle')) {
|
||||
e.preventDefault()
|
||||
input.preventDefault()
|
||||
notifyTerminalCapture('sidebar.right.toggle')
|
||||
actions.toggleRightSidebar()
|
||||
return
|
||||
|
|
@ -1573,7 +1593,7 @@ function App(): React.JSX.Element {
|
|||
|
||||
// Cmd/Ctrl+Shift+E — toggle right sidebar / explorer tab
|
||||
if (matchShortcut('sidebar.explorer.toggle')) {
|
||||
e.preventDefault()
|
||||
input.preventDefault()
|
||||
notifyTerminalCapture('sidebar.explorer.toggle')
|
||||
actions.showRightSidebarFiles()
|
||||
return
|
||||
|
|
@ -1581,7 +1601,7 @@ function App(): React.JSX.Element {
|
|||
|
||||
// Cmd/Ctrl+Shift+F — toggle right sidebar / search tab
|
||||
if (matchShortcut('sidebar.search.toggle')) {
|
||||
e.preventDefault()
|
||||
input.preventDefault()
|
||||
notifyTerminalCapture('sidebar.search.toggle')
|
||||
openSearchSidebar(null)
|
||||
return
|
||||
|
|
@ -1596,7 +1616,7 @@ function App(): React.JSX.Element {
|
|||
if (document.querySelector('[data-terminal-search-root]')) {
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
input.preventDefault()
|
||||
notifyTerminalCapture('sidebar.sourceControl.toggle')
|
||||
actions.setRightSidebarTab('source-control')
|
||||
actions.setRightSidebarOpen(true)
|
||||
|
|
@ -1604,7 +1624,7 @@ function App(): React.JSX.Element {
|
|||
}
|
||||
|
||||
if (matchShortcut('sidebar.checks.toggle')) {
|
||||
e.preventDefault()
|
||||
input.preventDefault()
|
||||
notifyTerminalCapture('sidebar.checks.toggle')
|
||||
actions.setRightSidebarTab('checks')
|
||||
actions.setRightSidebarOpen(true)
|
||||
|
|
@ -1615,15 +1635,79 @@ function App(): React.JSX.Element {
|
|||
// Why: Ctrl+Shift+I is the built-in DevTools accelerator on Windows/Linux;
|
||||
// intercepting it would break an essential developer tool.
|
||||
if (matchShortcut('sidebar.ports.toggle')) {
|
||||
e.preventDefault()
|
||||
input.preventDefault()
|
||||
notifyTerminalCapture('sidebar.ports.toggle')
|
||||
actions.setRightSidebarTab('ports')
|
||||
actions.setRightSidebarOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent): void => {
|
||||
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 (e.repeat) {
|
||||
return
|
||||
}
|
||||
if (detected) {
|
||||
// Synthetic input: no key/modifier flags, so only DoubleTap bindings match.
|
||||
dispatchShortcutInput({
|
||||
doubleTapModifier: detected.modifier,
|
||||
target: e.target,
|
||||
defaultPrevented: e.defaultPrevented,
|
||||
preventDefault: () => e.preventDefault()
|
||||
})
|
||||
return
|
||||
}
|
||||
dispatchShortcutInput({
|
||||
key: e.key,
|
||||
code: e.code,
|
||||
altKey: e.altKey,
|
||||
metaKey: e.metaKey,
|
||||
ctrlKey: e.ctrlKey,
|
||||
shiftKey: e.shiftKey,
|
||||
target: e.target,
|
||||
defaultPrevented: e.defaultPrevented,
|
||||
preventDefault: () => e.preventDefault()
|
||||
})
|
||||
}
|
||||
|
||||
const onKeyUp = (e: KeyboardEvent): void => {
|
||||
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()
|
||||
)
|
||||
}
|
||||
|
||||
// Why: a window blur mid-gesture must not leave the detector armed.
|
||||
const onBlur = (): void => doubleTapDetector.reset()
|
||||
|
||||
window.addEventListener('keydown', onKeyDown, { capture: true })
|
||||
return () => window.removeEventListener('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,
|
||||
activeWorktreeId,
|
||||
|
|
|
|||
|
|
@ -4,14 +4,14 @@ import { cn } from '../lib/utils'
|
|||
import { useAppStore } from '../store'
|
||||
import { isGitRepoKind } from '../../../shared/repo-kind'
|
||||
import { ShortcutKeyCombo } from './ShortcutKeyCombo'
|
||||
import { useShortcutKeys } from '@/hooks/useShortcutLabel'
|
||||
import { useShortcutKeyDetails, type ShortcutKeyComboDetails } from '@/hooks/useShortcutLabel'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import logo from '../../../../resources/logo.svg'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
type ShortcutItem = {
|
||||
id: string
|
||||
keys: string[]
|
||||
shortcut: ShortcutKeyComboDetails
|
||||
action: string
|
||||
}
|
||||
|
||||
|
|
@ -285,20 +285,20 @@ export default function Landing(): React.JSX.Element {
|
|||
}
|
||||
}, [preflightIssues.length])
|
||||
|
||||
const createWorktreeKeys = useShortcutKeys('workspace.create')
|
||||
const previousWorktreeKeys = useShortcutKeys('worktree.navigateUp')
|
||||
const nextWorktreeKeys = useShortcutKeys('worktree.navigateDown')
|
||||
const createWorktreeShortcut = useShortcutKeyDetails('workspace.create')
|
||||
const previousWorktreeShortcut = useShortcutKeyDetails('worktree.navigateUp')
|
||||
const nextWorktreeShortcut = useShortcutKeyDetails('worktree.navigateDown')
|
||||
const shortcuts = useMemo<ShortcutItem[]>(() => {
|
||||
return [
|
||||
{
|
||||
id: 'create',
|
||||
keys: createWorktreeKeys,
|
||||
shortcut: createWorktreeShortcut,
|
||||
action: `Create ${createTargetLabel.toLowerCase()}`
|
||||
},
|
||||
{ id: 'up', keys: previousWorktreeKeys, action: 'Move up workspace' },
|
||||
{ id: 'down', keys: nextWorktreeKeys, action: 'Move down workspace' }
|
||||
{ id: 'up', shortcut: previousWorktreeShortcut, action: 'Move up workspace' },
|
||||
{ id: 'down', shortcut: nextWorktreeShortcut, action: 'Move down workspace' }
|
||||
]
|
||||
}, [createTargetLabel, createWorktreeKeys, nextWorktreeKeys, previousWorktreeKeys])
|
||||
}, [createTargetLabel, createWorktreeShortcut, nextWorktreeShortcut, previousWorktreeShortcut])
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background">
|
||||
|
|
@ -359,7 +359,8 @@ export default function Landing(): React.JSX.Element {
|
|||
<div key={shortcut.id} className="grid grid-cols-[1fr_auto] items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground">{shortcut.action}</span>
|
||||
<ShortcutKeyCombo
|
||||
keys={shortcut.keys}
|
||||
keys={shortcut.shortcut.keys}
|
||||
doubleTap={shortcut.shortcut.doubleTap}
|
||||
separatorClassName="mx-0.5 text-[10px] text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
function KeyCap({ label, className }: { label: string; className?: string }): React.JSX.Element {
|
||||
return (
|
||||
|
|
@ -20,24 +21,33 @@ type ShortcutKeyComboProps = {
|
|||
separatorClassName?: string
|
||||
// Override cap colors when chips sit on a non-default surface (e.g. a filled primary card).
|
||||
keyCapClassName?: string
|
||||
// When true the chips render a double-tap gesture: no "+" separator (reads
|
||||
// "Shift Shift"), with a title clarifying the gesture. Note: the title uses
|
||||
// the displayed label, so on Mac it reads as the glyph (e.g. 'Double-tap ⇧').
|
||||
doubleTap?: boolean
|
||||
}
|
||||
|
||||
export function ShortcutKeyCombo({
|
||||
keys,
|
||||
className,
|
||||
separatorClassName,
|
||||
keyCapClassName
|
||||
keyCapClassName,
|
||||
doubleTap = false
|
||||
}: ShortcutKeyComboProps): React.JSX.Element {
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
|
||||
return (
|
||||
<span className={cn('inline-flex items-center gap-1', className)}>
|
||||
<span
|
||||
className={cn('inline-flex items-center gap-1', className)}
|
||||
title={doubleTap && keys.length > 0 ? translate("auto.components.ShortcutKeyCombo.07eb4985a1", "Double-tap {{value0}}", { value0: keys[0] }) : undefined}
|
||||
>
|
||||
{keys.map((key, index) => (
|
||||
<React.Fragment key={`${key}-${index}`}>
|
||||
<KeyCap label={key} className={keyCapClassName} />
|
||||
{/* Why: Orca renders Mac shortcuts as adjacent glyphs, but Windows/Linux
|
||||
shortcuts read more naturally with explicit "+" separators. */}
|
||||
{!isMac && index < keys.length - 1 ? (
|
||||
shortcuts read more naturally with explicit "+" separators. A
|
||||
double-tap reads as the same key twice, so it gets a space, not "+". */}
|
||||
{!isMac && !doubleTap && index < keys.length - 1 ? (
|
||||
<span className={separatorClassName ?? 'mx-0.5 text-xs text-muted-foreground'}>+</span>
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ import {
|
|||
} from '@/components/ui/dialog'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo'
|
||||
import { useShortcutKeys } from '@/hooks/useShortcutLabel'
|
||||
import { useShortcutKeyDetails, type ShortcutKeyComboDetails } from '@/hooks/useShortcutLabel'
|
||||
import { registerPendingEditorFlush } from './editor-pending-flush'
|
||||
import { editorShortcutMatches, installEditorSaveShortcut } from './editor-shortcuts'
|
||||
import MonacoCodeExcerpt from './MonacoCodeExcerpt'
|
||||
|
|
@ -264,13 +264,13 @@ function NotebookCellHeader({
|
|||
function NotebookHeaderButton({
|
||||
label,
|
||||
disabled = false,
|
||||
shortcutKeys,
|
||||
shortcut,
|
||||
onClick,
|
||||
children
|
||||
}: {
|
||||
label: string
|
||||
disabled?: boolean
|
||||
shortcutKeys?: string[]
|
||||
shortcut?: ShortcutKeyComboDetails
|
||||
onClick: () => void
|
||||
children: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
|
|
@ -292,7 +292,9 @@ function NotebookHeaderButton({
|
|||
<TooltipContent>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{label}</span>
|
||||
{shortcutKeys ? <ShortcutKeyCombo keys={shortcutKeys} /> : null}
|
||||
{shortcut && shortcut.keys.length > 0 ? (
|
||||
<ShortcutKeyCombo keys={shortcut.keys} doubleTap={shortcut.doubleTap} />
|
||||
) : null}
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
@ -729,7 +731,7 @@ export default function IpynbViewer({
|
|||
const latestContent = flushSourceDrafts()
|
||||
await onSave(latestContent)
|
||||
}, [flushSourceDrafts, onSave])
|
||||
const saveShortcutKeys = useShortcutKeys('editor.save')
|
||||
const saveShortcut = useShortcutKeyDetails('editor.save')
|
||||
|
||||
const handleNotebookKeyDownCapture = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>): void => {
|
||||
|
|
@ -885,7 +887,7 @@ export default function IpynbViewer({
|
|||
<div className="ml-auto flex items-center gap-2">
|
||||
<NotebookHeaderButton
|
||||
label={translate('auto.components.editor.IpynbViewer.15ec40a735', 'Save notebook')}
|
||||
shortcutKeys={saveShortcutKeys}
|
||||
shortcut={saveShortcut}
|
||||
onClick={() => void saveNotebook()}
|
||||
>
|
||||
<Save className="size-3.5" />
|
||||
|
|
|
|||
|
|
@ -7,16 +7,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|||
import { CmdJPaletteFeatureTipVisual } from './CmdJPaletteFeatureTipVisual'
|
||||
|
||||
const prefersReducedMotionMock = vi.hoisted(() => vi.fn(() => false))
|
||||
const shortcutKeysMock = vi.hoisted(() => vi.fn(() => ['⌘', 'J']))
|
||||
const formatShortcutKeysMock = vi.hoisted(() => vi.fn(() => ['⌘', 'J']))
|
||||
const shortcutMock = vi.hoisted(() => vi.fn(() => ({ keys: ['⌘', 'J'], doubleTap: false })))
|
||||
const formatShortcutMock = vi.hoisted(() => vi.fn(() => [{ keys: ['⌘', 'J'], doubleTap: false }]))
|
||||
|
||||
vi.mock('@/components/feature-wall/feature-wall-modal-helpers', () => ({
|
||||
usePrefersReducedMotion: prefersReducedMotionMock
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/useShortcutLabel', () => ({
|
||||
useShortcutKeys: shortcutKeysMock,
|
||||
formatShortcutKeys: formatShortcutKeysMock
|
||||
useShortcutKeyDetails: shortcutMock,
|
||||
formatShortcutKeyComboDetails: formatShortcutMock
|
||||
}))
|
||||
|
||||
async function renderVisual(): Promise<{ container: HTMLDivElement; root: Root }> {
|
||||
|
|
@ -34,8 +34,8 @@ async function renderVisual(): Promise<{ container: HTMLDivElement; root: Root }
|
|||
describe('CmdJPaletteFeatureTipVisual', () => {
|
||||
beforeEach(() => {
|
||||
prefersReducedMotionMock.mockReturnValue(false)
|
||||
shortcutKeysMock.mockReturnValue(['⌘', 'J'])
|
||||
formatShortcutKeysMock.mockReturnValue(['⌘', 'J'])
|
||||
shortcutMock.mockReturnValue({ keys: ['⌘', 'J'], doubleTap: false })
|
||||
formatShortcutMock.mockReturnValue([{ keys: ['⌘', 'J'], doubleTap: false }])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -110,12 +110,12 @@ describe('CmdJPaletteFeatureTipVisual', () => {
|
|||
})
|
||||
|
||||
it('falls back to default per-key chips when the live binding is unassigned', () => {
|
||||
shortcutKeysMock.mockReturnValue([])
|
||||
formatShortcutKeysMock.mockReturnValue(['Ctrl', 'Shift', 'J'])
|
||||
shortcutMock.mockReturnValue({ keys: [], doubleTap: false })
|
||||
formatShortcutMock.mockReturnValue([{ keys: ['Ctrl', 'Shift', 'J'], doubleTap: false }])
|
||||
|
||||
const html = renderToStaticMarkup(<CmdJPaletteFeatureTipVisual />)
|
||||
|
||||
expect(formatShortcutKeysMock).toHaveBeenCalledWith('worktree.palette')
|
||||
expect(formatShortcutMock).toHaveBeenCalledWith('worktree.palette')
|
||||
expect(html).toContain('Ctrl')
|
||||
expect(html).toContain('Shift')
|
||||
expect(html).toContain('J')
|
||||
|
|
@ -130,7 +130,7 @@ describe('CmdJPaletteFeatureTipVisual', () => {
|
|||
})
|
||||
|
||||
it('renders the live binding as separate shortcut key chips with plus separators', () => {
|
||||
shortcutKeysMock.mockReturnValue(['⌘', 'J'])
|
||||
shortcutMock.mockReturnValue({ keys: ['⌘', 'J'], doubleTap: false })
|
||||
|
||||
const html = renderToStaticMarkup(<CmdJPaletteFeatureTipVisual />)
|
||||
|
||||
|
|
@ -138,4 +138,13 @@ describe('CmdJPaletteFeatureTipVisual', () => {
|
|||
expect(html).toContain('J')
|
||||
expect(html).toContain('+')
|
||||
})
|
||||
|
||||
it('renders double-tap shortcut chips without plus separators', () => {
|
||||
shortcutMock.mockReturnValue({ keys: ['⇧', '⇧'], doubleTap: true })
|
||||
|
||||
const html = renderToStaticMarkup(<CmdJPaletteFeatureTipVisual />)
|
||||
|
||||
expect(html).toContain('⇧')
|
||||
expect(html).not.toContain('+')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Fragment, useEffect, useState, type JSX } from 'react'
|
||||
import { Plus, Search } from 'lucide-react'
|
||||
import { usePrefersReducedMotion } from '@/components/feature-wall/feature-wall-modal-helpers'
|
||||
import { formatShortcutKeys, useShortcutKeys } from '@/hooks/useShortcutLabel'
|
||||
import { formatShortcutKeyComboDetails, useShortcutKeyDetails } from '@/hooks/useShortcutLabel'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
const TYPED_QUERY = 'auth'
|
||||
|
|
@ -49,11 +49,13 @@ export function CmdJPaletteFeatureTipVisual(): JSX.Element {
|
|||
const reducedMotion = usePrefersReducedMotion()
|
||||
// Why: render the live binding so the cue stays correct after a rebind and on
|
||||
// platforms where Cmd+J is not the default (Linux/Windows use Ctrl+Shift+J).
|
||||
const shortcutKeys = useShortcutKeys('worktree.palette')
|
||||
const shortcut = useShortcutKeyDetails('worktree.palette')
|
||||
// Why: the press animation staggers per-key chips (⌘ then J); fall back to the
|
||||
// platform default when the user disables the binding.
|
||||
const displayShortcutKeys =
|
||||
shortcutKeys.length > 0 ? shortcutKeys : formatShortcutKeys('worktree.palette')
|
||||
const displayShortcut =
|
||||
shortcut.keys.length > 0 ? shortcut : formatShortcutKeyComboDetails('worktree.palette')[0]
|
||||
const displayShortcutKeys = displayShortcut?.keys ?? []
|
||||
const displayShortcutDoubleTap = displayShortcut?.doubleTap === true
|
||||
|
||||
const [phase, setPhase] = useState<CyclePhase>('idle')
|
||||
const [typedLength, setTypedLength] = useState(0)
|
||||
|
|
@ -135,7 +137,7 @@ export function CmdJPaletteFeatureTipVisual(): JSX.Element {
|
|||
<div className="inline-flex items-center gap-1.5">
|
||||
{displayShortcutKeys.map((key, index) => (
|
||||
<Fragment key={`${key}-${index}`}>
|
||||
{index > 0 ? (
|
||||
{index > 0 && !displayShortcutDoubleTap ? (
|
||||
<span className="text-xs text-muted-foreground" aria-hidden="true">
|
||||
+
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
* asserted without mounting the full Electron renderer. */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
|
||||
import type { KeybindingOverrides } from '../../../../shared/keybindings'
|
||||
import type { BrowserTab, Tab, TabGroup, TerminalTab } from '../../../../shared/types'
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
import { createUntitledMarkdownFileWithTemplateSelection } from '@/lib/create-untitled-markdown'
|
||||
|
|
@ -61,6 +62,7 @@ type FloatingPanelStoreState = {
|
|||
pinFile: (fileId: string, tabId?: string) => void
|
||||
openFile: (file: unknown, options?: unknown) => void
|
||||
browserDefaultUrl: string
|
||||
keybindings?: KeybindingOverrides
|
||||
tabBarOrderByWorktree: Record<string, string[]>
|
||||
settings: { activeRuntimeEnvironmentId?: string | null; floatingTerminalCwd?: string }
|
||||
}
|
||||
|
|
@ -430,6 +432,7 @@ function resetStore(tabs: TerminalTab[] = []): void {
|
|||
setTabColor: mocks.setTabColor,
|
||||
setTabPaneExpanded: mocks.setTabPaneExpanded,
|
||||
browserDefaultUrl: 'about:blank',
|
||||
keybindings: {},
|
||||
tabBarOrderByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: tabs.map((tab) => tab.id) },
|
||||
settings: { floatingTerminalCwd: '' }
|
||||
} satisfies FloatingPanelStoreState
|
||||
|
|
@ -1162,6 +1165,143 @@ describe('FloatingTerminalPanel close behavior', () => {
|
|||
expect(mocks.pickFloatingMarkdownDocument).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it('routes focused floating terminal double-tap shortcuts to the floating workspace', async () => {
|
||||
setFloatingTabs([makeTab({ id: 'tab-1' })])
|
||||
;(storeBox.state as FloatingPanelStoreState).keybindings = {
|
||||
'tab.newTerminal': ['DoubleTap+Shift']
|
||||
}
|
||||
const element = await renderPanel(true)
|
||||
const panel = findByProp(element, 'data-floating-terminal-panel')
|
||||
const panelElement = { contains: vi.fn().mockReturnValue(true), focus: vi.fn() }
|
||||
const target = {
|
||||
classList: { contains: vi.fn((token: string) => token === 'xterm-helper-textarea') },
|
||||
closest: vi.fn((selector: string) =>
|
||||
selector === '[data-floating-terminal-panel]' ? panelElement : null
|
||||
)
|
||||
}
|
||||
Object.setPrototypeOf(target, HTMLElement.prototype)
|
||||
attachRef(panel.props.ref, panelElement)
|
||||
vi.stubGlobal('document', {
|
||||
activeElement: target,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn()
|
||||
})
|
||||
runEffects()
|
||||
const keydownListener = vi
|
||||
.mocked(window.addEventListener)
|
||||
.mock.calls.find(([type]) => type === 'keydown')?.[1] as
|
||||
| ((event: unknown) => void)
|
||||
| undefined
|
||||
const keyupListener = vi
|
||||
.mocked(window.addEventListener)
|
||||
.mock.calls.find(([type]) => type === 'keyup')?.[1] as ((event: unknown) => void) | undefined
|
||||
if (!keydownListener || !keyupListener) {
|
||||
throw new Error('keyboard listeners not registered')
|
||||
}
|
||||
|
||||
const modifierEvent = {
|
||||
altKey: false,
|
||||
code: 'ShiftLeft',
|
||||
ctrlKey: false,
|
||||
defaultPrevented: false,
|
||||
key: 'Shift',
|
||||
metaKey: false,
|
||||
repeat: false,
|
||||
shiftKey: true,
|
||||
target
|
||||
}
|
||||
const firstPreventDefault = vi.fn()
|
||||
keydownListener({ ...modifierEvent, preventDefault: firstPreventDefault })
|
||||
keyupListener({ ...modifierEvent })
|
||||
const preventDefault = vi.fn()
|
||||
const stopPropagation = vi.fn()
|
||||
const stopImmediatePropagation = vi.fn()
|
||||
keydownListener({
|
||||
...modifierEvent,
|
||||
preventDefault,
|
||||
stopImmediatePropagation,
|
||||
stopPropagation
|
||||
})
|
||||
await flushAsyncWork()
|
||||
|
||||
expect(firstPreventDefault).not.toHaveBeenCalled()
|
||||
expect(preventDefault).toHaveBeenCalledWith()
|
||||
expect(stopPropagation).toHaveBeenCalledWith()
|
||||
expect(stopImmediatePropagation).toHaveBeenCalledWith()
|
||||
expect(mocks.createTab).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.createTab).toHaveBeenCalledWith(
|
||||
FLOATING_TERMINAL_WORKTREE_ID,
|
||||
'floating-group',
|
||||
undefined,
|
||||
{ activate: false }
|
||||
)
|
||||
expect(mocks.activateTab).toHaveBeenCalledWith('created-tab')
|
||||
})
|
||||
|
||||
it('resets focused floating terminal double-tap detection on window blur', async () => {
|
||||
setFloatingTabs([makeTab({ id: 'tab-1' })])
|
||||
;(storeBox.state as FloatingPanelStoreState).keybindings = {
|
||||
'tab.newTerminal': ['DoubleTap+Shift']
|
||||
}
|
||||
const element = await renderPanel(true)
|
||||
const panel = findByProp(element, 'data-floating-terminal-panel')
|
||||
const panelElement = { contains: vi.fn().mockReturnValue(true), focus: vi.fn() }
|
||||
const target = {
|
||||
classList: { contains: vi.fn((token: string) => token === 'xterm-helper-textarea') },
|
||||
closest: vi.fn((selector: string) =>
|
||||
selector === '[data-floating-terminal-panel]' ? panelElement : null
|
||||
)
|
||||
}
|
||||
Object.setPrototypeOf(target, HTMLElement.prototype)
|
||||
attachRef(panel.props.ref, panelElement)
|
||||
vi.stubGlobal('document', {
|
||||
activeElement: target,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn()
|
||||
})
|
||||
runEffects()
|
||||
const keydownListener = vi
|
||||
.mocked(window.addEventListener)
|
||||
.mock.calls.find(([type]) => type === 'keydown')?.[1] as
|
||||
| ((event: unknown) => void)
|
||||
| undefined
|
||||
const keyupListener = vi
|
||||
.mocked(window.addEventListener)
|
||||
.mock.calls.find(([type]) => type === 'keyup')?.[1] as ((event: unknown) => void) | undefined
|
||||
const blurListener = vi
|
||||
.mocked(window.addEventListener)
|
||||
.mock.calls.find(([type]) => type === 'blur')?.[1] as (() => void) | undefined
|
||||
if (!keydownListener || !keyupListener || !blurListener) {
|
||||
throw new Error('keyboard listeners not registered')
|
||||
}
|
||||
|
||||
const modifierEvent = {
|
||||
altKey: false,
|
||||
code: 'ShiftLeft',
|
||||
ctrlKey: false,
|
||||
defaultPrevented: false,
|
||||
key: 'Shift',
|
||||
metaKey: false,
|
||||
repeat: false,
|
||||
shiftKey: true,
|
||||
target
|
||||
}
|
||||
keydownListener({ ...modifierEvent, preventDefault: vi.fn() })
|
||||
keyupListener({ ...modifierEvent })
|
||||
blurListener()
|
||||
const preventDefault = vi.fn()
|
||||
keydownListener({
|
||||
...modifierEvent,
|
||||
preventDefault,
|
||||
stopImmediatePropagation: vi.fn(),
|
||||
stopPropagation: vi.fn()
|
||||
})
|
||||
await flushAsyncWork()
|
||||
|
||||
expect(preventDefault).not.toHaveBeenCalled()
|
||||
expect(mocks.createTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes focused floating tab switch shortcuts to the floating workspace', async () => {
|
||||
setFloatingTabs([makeTab({ id: 'tab-1' }), makeTab({ id: 'tab-2' })])
|
||||
const element = await renderPanel(true)
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import { resolveGroupTabFromVisibleId } from '@/components/tab-group/tab-group-v
|
|||
import TerminalPane from '@/components/terminal-pane/TerminalPane'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import { useShortcutKeys } from '@/hooks/useShortcutLabel'
|
||||
import { useShortcutKeyDetails, type ShortcutKeyComboDetails } from '@/hooks/useShortcutLabel'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -67,8 +67,13 @@ import {
|
|||
keybindingMatchesAction,
|
||||
type KeybindingActionId,
|
||||
type KeybindingContext,
|
||||
type KeybindingMatchOptions
|
||||
type KeybindingMatchOptions,
|
||||
type PhysicalModifierToken
|
||||
} from '../../../../shared/keybindings'
|
||||
import {
|
||||
ModifierDoubleTapDetector,
|
||||
toModifierDoubleTapEvent
|
||||
} from '../../../../shared/modifier-double-tap-detector'
|
||||
import type {
|
||||
BrowserTab as BrowserTabState,
|
||||
Tab,
|
||||
|
|
@ -116,6 +121,11 @@ type FloatingWorkspaceTourInteractionSnapshot = {
|
|||
recordFeatureInteractionForTour: boolean
|
||||
}
|
||||
|
||||
type FloatingPanelShortcutInput = Partial<
|
||||
Pick<KeyboardEvent, 'altKey' | 'code' | 'ctrlKey' | 'key' | 'metaKey' | 'shiftKey'>
|
||||
> &
|
||||
Pick<KeyboardEvent, 'target'> & { doubleTapModifier?: PhysicalModifierToken }
|
||||
|
||||
const FLOATING_TERMINAL_NO_DRAG_SELECTOR =
|
||||
'button,input,textarea,select,[role="menuitem"],[data-testid="sortable-tab"],[data-floating-terminal-no-drag]'
|
||||
const FLOATING_TERMINAL_SHORTCUT_SURFACE_SELECTOR = '[data-floating-terminal-shortcut-surface]'
|
||||
|
|
@ -194,11 +204,11 @@ export function FloatingTerminalPanel({
|
|||
const browserDefaultUrl = useAppStore((s) => s.browserDefaultUrl)
|
||||
const floatingTerminalCwd = useAppStore((s) => s.settings?.floatingTerminalCwd ?? '')
|
||||
const generatedTabTitlesEnabled = useAppStore((s) => s.settings?.tabAutoGenerateTitle === true)
|
||||
const newTerminalShortcutKeys = useShortcutKeys('tab.newTerminal')
|
||||
const newBrowserShortcutKeys = useShortcutKeys('tab.newBrowser')
|
||||
const newMarkdownShortcutKeys = useShortcutKeys('tab.newMarkdown')
|
||||
const openMarkdownShortcutKeys = useShortcutKeys('tab.openMarkdown')
|
||||
const closeShortcutKeys = useShortcutKeys('tab.close')
|
||||
const newTerminalShortcut = useShortcutKeyDetails('tab.newTerminal')
|
||||
const newBrowserShortcut = useShortcutKeyDetails('tab.newBrowser')
|
||||
const newMarkdownShortcut = useShortcutKeyDetails('tab.newMarkdown')
|
||||
const openMarkdownShortcut = useShortcutKeyDetails('tab.openMarkdown')
|
||||
const closeShortcut = useShortcutKeyDetails('tab.close')
|
||||
|
||||
const [cwd, setCwd] = useState<string | null>(null)
|
||||
const [markdownCwd, setMarkdownCwd] = useState<string | null>(null)
|
||||
|
|
@ -228,6 +238,10 @@ export function FloatingTerminalPanel({
|
|||
const pendingEditorCloseQueueRef = useRef<string[]>([])
|
||||
const saveDialogFileIdRef = useRef<string | null>(null)
|
||||
const panelRef = useRef<HTMLDivElement | null>(null)
|
||||
const doubleTapDetectorRef = useRef<ModifierDoubleTapDetector | null>(null)
|
||||
if (!doubleTapDetectorRef.current) {
|
||||
doubleTapDetectorRef.current = new ModifierDoubleTapDetector()
|
||||
}
|
||||
const shortcutFocusFrameRef = useRef<number | null>(null)
|
||||
const shortcutFocusTimeoutRef = useRef<number | null>(null)
|
||||
const mountedRef = useMountedRef()
|
||||
|
|
@ -926,6 +940,71 @@ export function FloatingTerminalPanel({
|
|||
setFloatingTerminalInputFocusedInMain(isFloatingWorkspaceTerminalInputTarget(target))
|
||||
}, [])
|
||||
|
||||
const handleFloatingPanelShortcutAction = useCallback(
|
||||
(input: FloatingPanelShortcutInput, consume: () => void): boolean => {
|
||||
const state = useAppStore.getState()
|
||||
const platform = getShortcutPlatform()
|
||||
const context: KeybindingContext = input.doubleTapModifier
|
||||
? 'app'
|
||||
: isFloatingWorkspaceTerminalInputTarget(input.target)
|
||||
? 'terminal'
|
||||
: 'app'
|
||||
const matchOptions: KeybindingMatchOptions = {
|
||||
context,
|
||||
terminalShortcutPolicy: state.settings?.terminalShortcutPolicy
|
||||
}
|
||||
const matches = (actionId: KeybindingActionId): boolean =>
|
||||
keybindingMatchesAction(actionId, input, platform, state.keybindings, matchOptions)
|
||||
|
||||
if (matches('tab.newTerminal')) {
|
||||
consume()
|
||||
createFloatingTerminalTab()
|
||||
return true
|
||||
}
|
||||
if (matches('tab.newBrowser')) {
|
||||
consume()
|
||||
createFloatingBrowserTab()
|
||||
return true
|
||||
}
|
||||
if (matches('tab.newMarkdown')) {
|
||||
consume()
|
||||
createFloatingMarkdownTab()
|
||||
return true
|
||||
}
|
||||
if (matches('tab.openMarkdown')) {
|
||||
consume()
|
||||
openFloatingMarkdownTab()
|
||||
return true
|
||||
}
|
||||
if (matches('tab.close')) {
|
||||
consume()
|
||||
if (activeClosableTab) {
|
||||
closeFloatingItem(activeClosableTab.id)
|
||||
if (visibleFloatingItemCount <= 1) {
|
||||
// Why: closing the final xterm removes the focused textarea; keep
|
||||
// the empty floating workspace as the owner for the next Cmd/Ctrl+T.
|
||||
focusPanelForShortcutsAfterClose()
|
||||
}
|
||||
} else {
|
||||
onOpenChange(false)
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
[
|
||||
activeClosableTab,
|
||||
closeFloatingItem,
|
||||
createFloatingBrowserTab,
|
||||
createFloatingMarkdownTab,
|
||||
createFloatingTerminalTab,
|
||||
focusPanelForShortcutsAfterClose,
|
||||
onOpenChange,
|
||||
openFloatingMarkdownTab,
|
||||
visibleFloatingItemCount
|
||||
]
|
||||
)
|
||||
|
||||
const handleShortcutSurfaceKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!open || event.defaultPrevented || event.repeat) {
|
||||
|
|
@ -950,8 +1029,6 @@ export function FloatingTerminalPanel({
|
|||
terminalShortcutPolicy: state.settings?.terminalShortcutPolicy
|
||||
}
|
||||
const nativeEvent = event.nativeEvent
|
||||
const matches = (actionId: KeybindingActionId): boolean =>
|
||||
keybindingMatchesAction(actionId, nativeEvent, platform, state.keybindings, matchOptions)
|
||||
|
||||
if (
|
||||
!isFloatingWorkspacePanelShortcut(
|
||||
|
|
@ -965,52 +1042,9 @@ export function FloatingTerminalPanel({
|
|||
return
|
||||
}
|
||||
|
||||
if (matches('tab.newTerminal')) {
|
||||
event.preventDefault()
|
||||
createFloatingTerminalTab()
|
||||
return
|
||||
}
|
||||
if (matches('tab.newBrowser')) {
|
||||
event.preventDefault()
|
||||
createFloatingBrowserTab()
|
||||
return
|
||||
}
|
||||
if (matches('tab.newMarkdown')) {
|
||||
event.preventDefault()
|
||||
createFloatingMarkdownTab()
|
||||
return
|
||||
}
|
||||
if (matches('tab.openMarkdown')) {
|
||||
event.preventDefault()
|
||||
openFloatingMarkdownTab()
|
||||
return
|
||||
}
|
||||
if (matches('tab.close')) {
|
||||
event.preventDefault()
|
||||
if (activeClosableTab) {
|
||||
closeFloatingItem(activeClosableTab.id)
|
||||
if (visibleFloatingItemCount <= 1) {
|
||||
// Why: closing the final xterm removes the focused textarea; keep
|
||||
// the empty floating workspace as the owner for the next Cmd/Ctrl+T.
|
||||
focusPanelForShortcutsAfterClose()
|
||||
}
|
||||
} else {
|
||||
onOpenChange(false)
|
||||
}
|
||||
}
|
||||
handleFloatingPanelShortcutAction(nativeEvent, () => event.preventDefault())
|
||||
},
|
||||
[
|
||||
activeClosableTab,
|
||||
closeFloatingItem,
|
||||
createFloatingBrowserTab,
|
||||
createFloatingMarkdownTab,
|
||||
createFloatingTerminalTab,
|
||||
focusPanelForShortcutsAfterClose,
|
||||
onOpenChange,
|
||||
openFloatingMarkdownTab,
|
||||
open,
|
||||
visibleFloatingItemCount
|
||||
]
|
||||
[handleFloatingPanelShortcutAction, open]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -1018,13 +1052,35 @@ export function FloatingTerminalPanel({
|
|||
return
|
||||
}
|
||||
|
||||
const handleFloatingPanelKeyDown = (event: KeyboardEvent): void => {
|
||||
if (event.defaultPrevented || event.repeat) {
|
||||
return
|
||||
}
|
||||
const isPanelFocused = (): boolean => {
|
||||
const panel = panelRef.current
|
||||
const active = document.activeElement
|
||||
if (!panel || !(active instanceof HTMLElement) || !panel.contains(active)) {
|
||||
return Boolean(panel && active instanceof HTMLElement && panel.contains(active))
|
||||
}
|
||||
|
||||
const handleFloatingPanelKeyDown = (event: KeyboardEvent): void => {
|
||||
if (event.defaultPrevented) {
|
||||
return
|
||||
}
|
||||
if (!isPanelFocused()) {
|
||||
doubleTapDetectorRef.current?.reset()
|
||||
return
|
||||
}
|
||||
|
||||
const detected = doubleTapDetectorRef.current?.process(
|
||||
toModifierDoubleTapEvent({
|
||||
type: 'keyDown',
|
||||
code: event.code,
|
||||
key: event.key,
|
||||
shift: event.shiftKey,
|
||||
control: event.ctrlKey,
|
||||
alt: event.altKey,
|
||||
meta: event.metaKey,
|
||||
isAutoRepeat: event.repeat
|
||||
}),
|
||||
Date.now()
|
||||
)
|
||||
if (event.repeat) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -1043,38 +1099,17 @@ export function FloatingTerminalPanel({
|
|||
event.stopImmediatePropagation()
|
||||
}
|
||||
|
||||
if (matches('tab.newTerminal')) {
|
||||
consume()
|
||||
createFloatingTerminalTab()
|
||||
if (
|
||||
detected &&
|
||||
handleFloatingPanelShortcutAction(
|
||||
{ doubleTapModifier: detected.modifier, target: event.target },
|
||||
consume
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (matches('tab.newBrowser')) {
|
||||
consume()
|
||||
createFloatingBrowserTab()
|
||||
return
|
||||
}
|
||||
if (matches('tab.newMarkdown')) {
|
||||
consume()
|
||||
createFloatingMarkdownTab()
|
||||
return
|
||||
}
|
||||
if (matches('tab.openMarkdown')) {
|
||||
consume()
|
||||
openFloatingMarkdownTab()
|
||||
return
|
||||
}
|
||||
if (matches('tab.close')) {
|
||||
consume()
|
||||
if (activeClosableTab) {
|
||||
closeFloatingItem(activeClosableTab.id)
|
||||
if (visibleFloatingItemCount <= 1) {
|
||||
// Why: closing the final xterm removes the focused textarea; keep
|
||||
// the empty floating workspace as the owner for the next Cmd/Ctrl+T.
|
||||
focusPanelForShortcutsAfterClose()
|
||||
}
|
||||
} else {
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
if (handleFloatingPanelShortcutAction(event, consume)) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -1109,23 +1144,39 @@ export function FloatingTerminalPanel({
|
|||
}
|
||||
}
|
||||
|
||||
const handleFloatingPanelKeyUp = (event: KeyboardEvent): void => {
|
||||
if (!isPanelFocused()) {
|
||||
doubleTapDetectorRef.current?.reset()
|
||||
return
|
||||
}
|
||||
doubleTapDetectorRef.current?.process(
|
||||
toModifierDoubleTapEvent({
|
||||
type: 'keyUp',
|
||||
code: event.code,
|
||||
key: event.key,
|
||||
shift: event.shiftKey,
|
||||
control: event.ctrlKey,
|
||||
alt: event.altKey,
|
||||
meta: event.metaKey
|
||||
}),
|
||||
Date.now()
|
||||
)
|
||||
}
|
||||
|
||||
const handleFloatingPanelBlur = (): void => doubleTapDetectorRef.current?.reset()
|
||||
|
||||
// Why: the main Terminal view is not mounted on Landing/Settings, but the
|
||||
// floating workspace must still own its tab shortcuts while it has focus.
|
||||
window.addEventListener('keydown', handleFloatingPanelKeyDown, { capture: true })
|
||||
return () =>
|
||||
window.addEventListener('keyup', handleFloatingPanelKeyUp, { capture: true })
|
||||
window.addEventListener('blur', handleFloatingPanelBlur)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleFloatingPanelKeyDown, { capture: true })
|
||||
}, [
|
||||
activeClosableTab,
|
||||
closeFloatingItem,
|
||||
createFloatingBrowserTab,
|
||||
createFloatingMarkdownTab,
|
||||
createFloatingTerminalTab,
|
||||
focusPanelForShortcutsAfterClose,
|
||||
onOpenChange,
|
||||
openFloatingMarkdownTab,
|
||||
open,
|
||||
visibleFloatingItemCount
|
||||
])
|
||||
window.removeEventListener('keyup', handleFloatingPanelKeyUp, { capture: true })
|
||||
window.removeEventListener('blur', handleFloatingPanelBlur)
|
||||
doubleTapDetectorRef.current?.reset()
|
||||
}
|
||||
}, [handleFloatingPanelShortcutAction, open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
|
|
@ -1451,11 +1502,11 @@ export function FloatingTerminalPanel({
|
|||
onNewBrowser={createFloatingBrowserTab}
|
||||
onClose={() => onOpenChange(false)}
|
||||
onFocusPanel={focusPanelForShortcuts}
|
||||
newTerminalShortcutKeys={newTerminalShortcutKeys}
|
||||
newBrowserShortcutKeys={newBrowserShortcutKeys}
|
||||
newMarkdownShortcutKeys={newMarkdownShortcutKeys}
|
||||
openMarkdownShortcutKeys={openMarkdownShortcutKeys}
|
||||
closeShortcutKeys={closeShortcutKeys}
|
||||
newTerminalShortcut={newTerminalShortcut}
|
||||
newBrowserShortcut={newBrowserShortcut}
|
||||
newMarkdownShortcut={newMarkdownShortcut}
|
||||
openMarkdownShortcut={openMarkdownShortcut}
|
||||
closeShortcut={closeShortcut}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -1593,11 +1644,11 @@ function FloatingTerminalEmptyState({
|
|||
onNewBrowser,
|
||||
onClose,
|
||||
onFocusPanel,
|
||||
newTerminalShortcutKeys,
|
||||
newBrowserShortcutKeys,
|
||||
newMarkdownShortcutKeys,
|
||||
openMarkdownShortcutKeys,
|
||||
closeShortcutKeys
|
||||
newTerminalShortcut,
|
||||
newBrowserShortcut,
|
||||
newMarkdownShortcut,
|
||||
openMarkdownShortcut,
|
||||
closeShortcut
|
||||
}: {
|
||||
onNewTerminal: () => void
|
||||
onNewMarkdown: () => void
|
||||
|
|
@ -1605,11 +1656,11 @@ function FloatingTerminalEmptyState({
|
|||
onNewBrowser: () => void
|
||||
onClose: () => void
|
||||
onFocusPanel: () => void
|
||||
newTerminalShortcutKeys: string[]
|
||||
newBrowserShortcutKeys: string[]
|
||||
newMarkdownShortcutKeys: string[]
|
||||
openMarkdownShortcutKeys: string[]
|
||||
closeShortcutKeys: string[]
|
||||
newTerminalShortcut: ShortcutKeyComboDetails
|
||||
newBrowserShortcut: ShortcutKeyComboDetails
|
||||
newMarkdownShortcut: ShortcutKeyComboDetails
|
||||
openMarkdownShortcut: ShortcutKeyComboDetails
|
||||
closeShortcut: ShortcutKeyComboDetails
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div
|
||||
|
|
@ -1633,7 +1684,7 @@ function FloatingTerminalEmptyState({
|
|||
'New Terminal'
|
||||
)}
|
||||
</span>
|
||||
<FloatingEmptyStateShortcut keys={newTerminalShortcutKeys} />
|
||||
<FloatingEmptyStateShortcut shortcut={newTerminalShortcut} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -1649,7 +1700,7 @@ function FloatingTerminalEmptyState({
|
|||
'New Markdown Note'
|
||||
)}
|
||||
</span>
|
||||
<FloatingEmptyStateShortcut keys={newMarkdownShortcutKeys} />
|
||||
<FloatingEmptyStateShortcut shortcut={newMarkdownShortcut} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -1664,7 +1715,7 @@ function FloatingTerminalEmptyState({
|
|||
'Open Markdown Note'
|
||||
)}
|
||||
</span>
|
||||
<FloatingEmptyStateShortcut keys={openMarkdownShortcutKeys} />
|
||||
<FloatingEmptyStateShortcut shortcut={openMarkdownShortcut} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -1679,7 +1730,7 @@ function FloatingTerminalEmptyState({
|
|||
'New Browser'
|
||||
)}
|
||||
</span>
|
||||
<FloatingEmptyStateShortcut keys={newBrowserShortcutKeys} />
|
||||
<FloatingEmptyStateShortcut shortcut={newBrowserShortcut} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -1694,20 +1745,25 @@ function FloatingTerminalEmptyState({
|
|||
'Minimize'
|
||||
)}
|
||||
</span>
|
||||
<FloatingEmptyStateShortcut keys={closeShortcutKeys} />
|
||||
<FloatingEmptyStateShortcut shortcut={closeShortcut} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FloatingEmptyStateShortcut({ keys }: { keys: string[] }): React.JSX.Element {
|
||||
if (keys.length === 0) {
|
||||
function FloatingEmptyStateShortcut({
|
||||
shortcut
|
||||
}: {
|
||||
shortcut: ShortcutKeyComboDetails
|
||||
}): React.JSX.Element {
|
||||
if (shortcut.keys.length === 0) {
|
||||
return <span aria-hidden />
|
||||
}
|
||||
return (
|
||||
<ShortcutKeyCombo
|
||||
keys={keys}
|
||||
keys={shortcut.keys}
|
||||
doubleTap={shortcut.doubleTap}
|
||||
className="self-center justify-self-end opacity-90 [&>span]:text-foreground"
|
||||
separatorClassName="mx-0 text-[9px] text-foreground"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ vi.mock('../../store', () => ({
|
|||
}))
|
||||
|
||||
vi.mock('@/hooks/useShortcutLabel', () => ({
|
||||
useShortcutKeyCombos: () => []
|
||||
useShortcutKeyComboDetails: () => []
|
||||
}))
|
||||
|
||||
vi.mock('../status-bar/use-available-status-bar-toggles', () => ({
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { UIZoomControl } from './UIZoomControl'
|
|||
import { SearchableSetting } from './SearchableSetting'
|
||||
import { matchesSettingsSearch } from './settings-search'
|
||||
import { useAppStore } from '../../store'
|
||||
import { useShortcutKeyCombos } from '@/hooks/useShortcutLabel'
|
||||
import { useShortcutKeyComboDetails, type ShortcutKeyComboDetails } from '@/hooks/useShortcutLabel'
|
||||
import { ShortcutKeyCombo } from '../ShortcutKeyCombo'
|
||||
import {
|
||||
FontAutocomplete,
|
||||
|
|
@ -64,7 +64,7 @@ type AppearancePaneProps = {
|
|||
warpThemes: UseWarpThemeImportReturn
|
||||
}
|
||||
|
||||
function ShortcutHintList({ combos }: { combos: string[][] }): React.JSX.Element {
|
||||
function ShortcutHintList({ combos }: { combos: ShortcutKeyComboDetails[] }): React.JSX.Element {
|
||||
if (combos.length === 0) {
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
|
|
@ -75,10 +75,11 @@ function ShortcutHintList({ combos }: { combos: string[][] }): React.JSX.Element
|
|||
|
||||
return (
|
||||
<span className="inline-flex flex-wrap items-center gap-1 align-middle">
|
||||
{combos.map((keys) => (
|
||||
{combos.map((combo) => (
|
||||
<ShortcutKeyCombo
|
||||
key={keys.join('-')}
|
||||
keys={keys}
|
||||
key={combo.keys.join('-')}
|
||||
keys={combo.keys}
|
||||
doubleTap={combo.doubleTap}
|
||||
className="inline-flex gap-0.5"
|
||||
separatorClassName="text-[10px] text-muted-foreground"
|
||||
/>
|
||||
|
|
@ -98,8 +99,8 @@ export function AppearancePane({
|
|||
warpThemes
|
||||
}: AppearancePaneProps): React.JSX.Element {
|
||||
const searchQuery = useAppStore((state) => state.settingsSearchQuery)
|
||||
const zoomInKeyCombos = useShortcutKeyCombos('zoom.in')
|
||||
const zoomOutKeyCombos = useShortcutKeyCombos('zoom.out')
|
||||
const zoomInKeyCombos = useShortcutKeyComboDetails('zoom.in')
|
||||
const zoomOutKeyCombos = useShortcutKeyComboDetails('zoom.out')
|
||||
const statusBarItems = useAppStore((state) => state.statusBarItems)
|
||||
const toggleStatusBarItem = useAppStore((state) => state.toggleStatusBarItem)
|
||||
const recordFeatureInteraction = useAppStore((state) => state.recordFeatureInteraction)
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ const mocks = vi.hoisted(() => ({
|
|||
|
||||
vi.mock('@/hooks/useShortcutLabel', () => ({
|
||||
useShortcutLabel: () => '⌘F',
|
||||
useShortcutKeyCombos: () => [['⌘', 'F']]
|
||||
useShortcutKeyComboDetails: () => [{ keys: ['⌘', 'F'], doubleTap: false }]
|
||||
}))
|
||||
|
||||
vi.mock('./settings-setup-guide-progress', () => ({
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { ArrowLeft, Search, Server } from 'lucide-react'
|
|||
import type { RepoIcon } from '../../../../shared/repo-icon'
|
||||
import type { SettingsNavIcon, SettingsNavInstallStatus } from '@/lib/settings-navigation-types'
|
||||
import type { GitHubRepositoryIdentity, GlobalSettings } from '../../../../shared/types'
|
||||
import { useShortcutKeyCombos } from '@/hooks/useShortcutLabel'
|
||||
import { useShortcutKeyComboDetails } from '@/hooks/useShortcutLabel'
|
||||
import { ShortcutKeyCombo } from '../ShortcutKeyCombo'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { RepoIconGlyph } from '../repo/repo-icon'
|
||||
|
|
@ -138,7 +138,7 @@ export function SettingsSidebar({
|
|||
// Settings should remain a stable place to reopen the checklist.
|
||||
const showSetupGuideTopRow =
|
||||
setupGuideProgress.ready && setupGuideProgress.doneCount < setupGuideProgress.total
|
||||
const searchShortcutCombos = useShortcutKeyCombos('settings.search')
|
||||
const searchShortcutCombos = useShortcutKeyComboDetails('settings.search')
|
||||
const navItemClassName = (isActive: boolean): string =>
|
||||
cn(
|
||||
'flex w-full items-center gap-2 rounded-lg px-3 py-1.5 text-left text-[13px] outline-none transition-colors duration-150 focus-visible:ring-[3px] focus-visible:ring-worktree-sidebar-ring/50',
|
||||
|
|
@ -201,10 +201,11 @@ export function SettingsSidebar({
|
|||
/>
|
||||
{searchQuery === '' ? (
|
||||
<span className="pointer-events-none absolute right-2 top-1/2 flex -translate-y-1/2 items-center">
|
||||
{searchShortcutCombos.map((keys) => (
|
||||
{searchShortcutCombos.map((combo) => (
|
||||
<ShortcutKeyCombo
|
||||
key={keys.join('-')}
|
||||
keys={keys}
|
||||
key={combo.keys.join('-')}
|
||||
keys={combo.keys}
|
||||
doubleTap={combo.doubleTap}
|
||||
className="inline-flex gap-0.5"
|
||||
separatorClassName="text-[10px] text-muted-foreground"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -2,10 +2,16 @@ import React, { useEffect, useRef } from 'react'
|
|||
import { Ban, Plus, RotateCcw, Terminal } from 'lucide-react'
|
||||
import {
|
||||
formatKeybinding,
|
||||
isDoubleTapBinding,
|
||||
type KeybindingActionId,
|
||||
type KeybindingDefinition,
|
||||
type KeybindingInput
|
||||
} from '../../../../shared/keybindings'
|
||||
import {
|
||||
ModifierDoubleTapDetector,
|
||||
modifierFromKeyEvent,
|
||||
toModifierDoubleTapEvent
|
||||
} from '../../../../shared/modifier-double-tap-detector'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { ShortcutKeyCombo } from '../ShortcutKeyCombo'
|
||||
import { Badge } from '../ui/badge'
|
||||
|
|
@ -55,17 +61,31 @@ export function ShortcutBindingRow({
|
|||
onReset
|
||||
}: ShortcutBindingRowProps): React.JSX.Element {
|
||||
const recordButtonRef = useRef<HTMLButtonElement | null>(null)
|
||||
const doubleTapDetectorRef = useRef<ModifierDoubleTapDetector | null>(null)
|
||||
if (!doubleTapDetectorRef.current) {
|
||||
doubleTapDetectorRef.current = new ModifierDoubleTapDetector()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (recording) {
|
||||
recordButtonRef.current?.focus()
|
||||
} else {
|
||||
// Stale taps mustn't survive into the next recording session.
|
||||
doubleTapDetectorRef.current?.reset()
|
||||
}
|
||||
window.api.ui.setShortcutRecorderFocused(recording)
|
||||
return () => window.api.ui.setShortcutRecorderFocused(false)
|
||||
}, [recording])
|
||||
|
||||
const statusMessage = error ?? (warnings.length > 0 ? warnings.join(' ') : '')
|
||||
const recordingMessage = recording ? 'Listening for shortcut. Esc cancels recording.' : ''
|
||||
const doubleTapHint = platform === 'darwin' ? '⇧⇧' : 'Shift Shift'
|
||||
const recordingMessage = recording
|
||||
? translate(
|
||||
'auto.components.settings.ShortcutBindingRow.a98d551407',
|
||||
'Press a shortcut, or double-tap a modifier (e.g. {{value0}}). Esc cancels.',
|
||||
{ value0: doubleTapHint }
|
||||
)
|
||||
: ''
|
||||
const helperMessage = statusMessage || recordingMessage
|
||||
const hasBinding = effective.length > 0
|
||||
|
||||
|
|
@ -82,11 +102,37 @@ export function ShortcutBindingRow({
|
|||
event.stopPropagation()
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
doubleTapDetectorRef.current?.reset()
|
||||
onClearError(item.id)
|
||||
onCancelRecording()
|
||||
return
|
||||
}
|
||||
|
||||
// A modifier press never captures on its own — the detector decides whether
|
||||
// it completes a double-tap, leaving normal chords to capture on their key.
|
||||
if (modifierFromKeyEvent(event.code, event.key) !== null) {
|
||||
const detected = doubleTapDetectorRef.current?.process(
|
||||
toModifierDoubleTapEvent({
|
||||
type: 'keyDown',
|
||||
code: event.code,
|
||||
key: event.key,
|
||||
shift: event.shiftKey,
|
||||
control: event.ctrlKey,
|
||||
alt: event.altKey,
|
||||
meta: event.metaKey,
|
||||
isAutoRepeat: event.repeat
|
||||
}),
|
||||
Date.now()
|
||||
)
|
||||
if (detected) {
|
||||
onClearError(item.id)
|
||||
onCapture(item.id, { doubleTapModifier: detected.modifier })
|
||||
doubleTapDetectorRef.current?.reset()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
doubleTapDetectorRef.current?.reset()
|
||||
onClearError(item.id)
|
||||
onCapture(item.id, {
|
||||
key: event.key,
|
||||
|
|
@ -98,6 +144,26 @@ export function ShortcutBindingRow({
|
|||
})
|
||||
}
|
||||
|
||||
const handleRecordKeyUp = (event: React.KeyboardEvent<HTMLButtonElement>): void => {
|
||||
if (!recording) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
doubleTapDetectorRef.current?.process(
|
||||
toModifierDoubleTapEvent({
|
||||
type: 'keyUp',
|
||||
code: event.code,
|
||||
key: event.key,
|
||||
shift: event.shiftKey,
|
||||
control: event.ctrlKey,
|
||||
alt: event.altKey,
|
||||
meta: event.metaKey
|
||||
}),
|
||||
Date.now()
|
||||
)
|
||||
}
|
||||
|
||||
// Why: the recorder is the row's primary control — clicking the keys (or the
|
||||
// "Add shortcut" placeholder) records a new binding in place, so the whole
|
||||
// affordance lives inline rather than in a detached popover.
|
||||
|
|
@ -230,6 +296,7 @@ export function ShortcutBindingRow({
|
|||
}
|
||||
}}
|
||||
onKeyDown={handleRecordKeyDown}
|
||||
onKeyUp={handleRecordKeyUp}
|
||||
className={cn(
|
||||
'flex min-h-7 min-w-[5.5rem] items-center justify-center gap-1.5 rounded-md border px-2 py-1 text-xs outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50',
|
||||
recording
|
||||
|
|
@ -249,7 +316,11 @@ export function ShortcutBindingRow({
|
|||
) : hasBinding ? (
|
||||
<span className="flex flex-wrap items-center justify-end gap-1.5">
|
||||
{effective.map((binding) => (
|
||||
<ShortcutKeyCombo key={binding} keys={formatKeybinding(binding, platform)} />
|
||||
<ShortcutKeyCombo
|
||||
key={binding}
|
||||
keys={formatKeybinding(binding, platform)}
|
||||
doubleTap={isDoubleTapBinding(binding)}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ const mocks = vi.hoisted(() => ({
|
|||
appRestart: vi.fn(),
|
||||
updaterCheck: vi.fn(),
|
||||
shellOpenUrl: vi.fn(),
|
||||
useShortcutKeys: vi.fn(),
|
||||
useShortcutKeyDetails: vi.fn(),
|
||||
setupProgress: {
|
||||
ready: true,
|
||||
coreDoneCount: 2,
|
||||
|
|
@ -32,7 +32,7 @@ vi.mock('@/store', () => ({
|
|||
}))
|
||||
|
||||
vi.mock('@/hooks/useShortcutLabel', () => ({
|
||||
useShortcutKeys: mocks.useShortcutKeys
|
||||
useShortcutKeyDetails: mocks.useShortcutKeyDetails
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/useMountedRef', () => ({
|
||||
|
|
@ -99,7 +99,7 @@ vi.mock('./SidebarFeedbackDialog', () => ({
|
|||
describe('SidebarSettingsHelpMenu', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.useShortcutKeys.mockReturnValue(['⌘', ','])
|
||||
mocks.useShortcutKeyDetails.mockReturnValue({ keys: ['⌘', ','], doubleTap: false })
|
||||
updateStatus = { state: 'idle' }
|
||||
mocks.setupProgress = {
|
||||
ready: true,
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ import {
|
|||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import { useShortcutKeys } from '@/hooks/useShortcutLabel'
|
||||
import { useShortcutKeyDetails } from '@/hooks/useShortcutLabel'
|
||||
import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo'
|
||||
import { showOnboardingFromRenderer } from '../onboarding/show-onboarding-event'
|
||||
import { SetupGuideProgressRing } from '../setup-guide/SetupGuideProgressRing'
|
||||
|
|
@ -85,7 +85,7 @@ export function SidebarSettingsHelpMenu(): React.JSX.Element {
|
|||
const updateStatus = useAppStore((s) => s.updateStatus)
|
||||
const setupProgress = useSetupGuideProgress(true, false, false)
|
||||
|
||||
const settingsShortcutKeys = useShortcutKeys('app.settings')
|
||||
const settingsShortcut = useShortcutKeyDetails('app.settings')
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [feedbackOpen, setFeedbackOpen] = useState(false)
|
||||
const [showAdminOptions, setShowAdminOptions] = useState(false)
|
||||
|
|
@ -177,9 +177,10 @@ export function SidebarSettingsHelpMenu(): React.JSX.Element {
|
|||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4} className="flex items-center gap-1.5">
|
||||
{translate('auto.components.sidebar.SidebarSettingsHelpMenu.a428c25998', 'Settings')}
|
||||
{settingsShortcutKeys.length > 0 ? (
|
||||
{settingsShortcut.keys.length > 0 ? (
|
||||
<ShortcutKeyCombo
|
||||
keys={settingsShortcutKeys}
|
||||
keys={settingsShortcut.keys}
|
||||
doubleTap={settingsShortcut.doubleTap}
|
||||
className="gap-0.5"
|
||||
keyCapClassName="min-w-0 border-background/20 bg-background/10 px-1 py-0 text-[10px] text-background shadow-none"
|
||||
separatorClassName="text-[10px] text-background/70"
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import {
|
|||
formatKeybinding,
|
||||
formatKeybindingList,
|
||||
getEffectiveKeybindingsForAction,
|
||||
isDoubleTapBinding,
|
||||
type KeybindingActionId,
|
||||
type KeybindingOverrides
|
||||
} from '../../../shared/keybindings'
|
||||
|
|
@ -10,6 +11,11 @@ import { getShortcutPlatform } from '../lib/shortcut-platform'
|
|||
|
||||
export { getShortcutPlatform }
|
||||
|
||||
export type ShortcutKeyComboDetails = {
|
||||
keys: string[]
|
||||
doubleTap: boolean
|
||||
}
|
||||
|
||||
export function formatShortcutLabel(
|
||||
actionId: KeybindingActionId,
|
||||
overrides?: KeybindingOverrides
|
||||
|
|
@ -30,9 +36,7 @@ export function formatShortcutKeys(
|
|||
actionId: KeybindingActionId,
|
||||
overrides?: KeybindingOverrides
|
||||
): string[] {
|
||||
const platform = getShortcutPlatform()
|
||||
const binding = getEffectiveKeybindingsForAction(actionId, platform, overrides)[0]
|
||||
return binding ? formatKeybinding(binding, platform) : []
|
||||
return formatShortcutKeyComboDetails(actionId, overrides)[0]?.keys ?? []
|
||||
}
|
||||
|
||||
export function useShortcutKeys(actionId: KeybindingActionId): string[] {
|
||||
|
|
@ -40,17 +44,35 @@ export function useShortcutKeys(actionId: KeybindingActionId): string[] {
|
|||
return formatShortcutKeys(actionId, keybindings)
|
||||
}
|
||||
|
||||
export function formatShortcutKeyComboDetails(
|
||||
actionId: KeybindingActionId,
|
||||
overrides?: KeybindingOverrides
|
||||
): ShortcutKeyComboDetails[] {
|
||||
const platform = getShortcutPlatform()
|
||||
return getEffectiveKeybindingsForAction(actionId, platform, overrides).map((binding) => ({
|
||||
keys: formatKeybinding(binding, platform),
|
||||
doubleTap: isDoubleTapBinding(binding)
|
||||
}))
|
||||
}
|
||||
|
||||
export function useShortcutKeyComboDetails(
|
||||
actionId: KeybindingActionId
|
||||
): ShortcutKeyComboDetails[] {
|
||||
const keybindings = useAppStore((state) => state.keybindings)
|
||||
return formatShortcutKeyComboDetails(actionId, keybindings)
|
||||
}
|
||||
|
||||
export function useShortcutKeyDetails(actionId: KeybindingActionId): ShortcutKeyComboDetails {
|
||||
return useShortcutKeyComboDetails(actionId)[0] ?? { keys: [], doubleTap: false }
|
||||
}
|
||||
|
||||
export function formatShortcutKeyCombos(
|
||||
actionId: KeybindingActionId,
|
||||
overrides?: KeybindingOverrides
|
||||
): string[][] {
|
||||
const platform = getShortcutPlatform()
|
||||
return getEffectiveKeybindingsForAction(actionId, platform, overrides).map((binding) =>
|
||||
formatKeybinding(binding, platform)
|
||||
)
|
||||
return formatShortcutKeyComboDetails(actionId, overrides).map((combo) => combo.keys)
|
||||
}
|
||||
|
||||
export function useShortcutKeyCombos(actionId: KeybindingActionId): string[][] {
|
||||
const keybindings = useAppStore((state) => state.keybindings)
|
||||
return formatShortcutKeyCombos(actionId, keybindings)
|
||||
return useShortcutKeyComboDetails(actionId).map((combo) => combo.keys)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5791,7 +5791,8 @@
|
|||
"97dccee14e": "Modified",
|
||||
"3b11ef3a43": "{{value0}} shortcut",
|
||||
"f6579be67b": "Change shortcut",
|
||||
"6a7848fdac": "Listening for shortcut"
|
||||
"6a7848fdac": "Listening for shortcut",
|
||||
"a98d551407": "Press a shortcut, or double-tap a modifier (e.g. {{value0}}). Esc cancels."
|
||||
},
|
||||
"ShortcutFilterRail": {
|
||||
"28b63545bf": "Status",
|
||||
|
|
@ -11451,6 +11452,9 @@
|
|||
"sourceUnavailable": "{{value0}} source unavailable: {{value1}}",
|
||||
"someSourceHostsUnavailable": "Some {{value0}} source hosts unavailable: {{value1}}",
|
||||
"reconnectOrUpdateTitle": "Reconnect or update {{value0}} to load this source."
|
||||
},
|
||||
"ShortcutKeyCombo": {
|
||||
"07eb4985a1": "Double-tap {{value0}}"
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
|
|
|
|||
|
|
@ -5754,7 +5754,8 @@
|
|||
"97dccee14e": "Modificado",
|
||||
"3b11ef3a43": "{{value0}} atajo",
|
||||
"f6579be67b": "Cambiar acceso directo",
|
||||
"6a7848fdac": "Escuchando atajos"
|
||||
"6a7848fdac": "Escuchando atajos",
|
||||
"a98d551407": "Pulsa un atajo o toca dos veces una tecla modificadora (p. ej., {{value0}}). Esc cancela."
|
||||
},
|
||||
"ShortcutFilterRail": {
|
||||
"28b63545bf": "Estado",
|
||||
|
|
@ -11451,6 +11452,9 @@
|
|||
"sourceUnavailable": "{{value0}} source unavailable: {{value1}}",
|
||||
"someSourceHostsUnavailable": "Some {{value0}} source hosts unavailable: {{value1}}",
|
||||
"reconnectOrUpdateTitle": "Reconnect or update {{value0}} to load this source."
|
||||
},
|
||||
"ShortcutKeyCombo": {
|
||||
"07eb4985a1": "Double-tap {{value0}}"
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
|
|
|
|||
|
|
@ -5776,7 +5776,8 @@
|
|||
"97dccee14e": "修正済み",
|
||||
"3b11ef3a43": "{{value0}} ショートカット",
|
||||
"f6579be67b": "ショートカットを変更する",
|
||||
"6a7848fdac": "ショートカットを記録中"
|
||||
"6a7848fdac": "ショートカットを記録中",
|
||||
"a98d551407": "ショートカットを押すか、修飾キーをダブルタップしてください(例: {{value0}})。Esc でキャンセルします。"
|
||||
},
|
||||
"ShortcutFilterRail": {
|
||||
"28b63545bf": "状態",
|
||||
|
|
@ -11451,6 +11452,9 @@
|
|||
"sourceUnavailable": "{{value0}} source unavailable: {{value1}}",
|
||||
"someSourceHostsUnavailable": "Some {{value0}} source hosts unavailable: {{value1}}",
|
||||
"reconnectOrUpdateTitle": "Reconnect or update {{value0}} to load this source."
|
||||
},
|
||||
"ShortcutKeyCombo": {
|
||||
"07eb4985a1": "Double-tap {{value0}}"
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
|
|
|
|||
|
|
@ -5739,7 +5739,8 @@
|
|||
"97dccee14e": "수정됨",
|
||||
"3b11ef3a43": "{{value0}} 바로가기",
|
||||
"f6579be67b": "바로가기 변경",
|
||||
"6a7848fdac": "단축키 입력 대기 중"
|
||||
"6a7848fdac": "단축키 입력 대기 중",
|
||||
"a98d551407": "단축키를 누르거나 보조 키를 두 번 탭하세요(예: {{value0}}). Esc로 취소합니다."
|
||||
},
|
||||
"ShortcutFilterRail": {
|
||||
"28b63545bf": "상태",
|
||||
|
|
@ -11451,6 +11452,9 @@
|
|||
"sourceUnavailable": "{{value0}} source unavailable: {{value1}}",
|
||||
"someSourceHostsUnavailable": "Some {{value0}} source hosts unavailable: {{value1}}",
|
||||
"reconnectOrUpdateTitle": "Reconnect or update {{value0}} to load this source."
|
||||
},
|
||||
"ShortcutKeyCombo": {
|
||||
"07eb4985a1": "Double-tap {{value0}}"
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
|
|
|
|||
|
|
@ -5739,7 +5739,8 @@
|
|||
"97dccee14e": "修改的",
|
||||
"3b11ef3a43": "{{value0}} 快捷方式",
|
||||
"f6579be67b": "更改快捷方式",
|
||||
"6a7848fdac": "正在录制快捷键"
|
||||
"6a7848fdac": "正在录制快捷键",
|
||||
"a98d551407": "按下快捷键,或双击一个修饰键(例如 {{value0}})。按 Esc 取消。"
|
||||
},
|
||||
"ShortcutFilterRail": {
|
||||
"28b63545bf": "状态",
|
||||
|
|
@ -11451,6 +11452,9 @@
|
|||
"sourceUnavailable": "{{value0}} source unavailable: {{value1}}",
|
||||
"someSourceHostsUnavailable": "Some {{value0}} source hosts unavailable: {{value1}}",
|
||||
"reconnectOrUpdateTitle": "Reconnect or update {{value0}} to load this source."
|
||||
},
|
||||
"ShortcutKeyCombo": {
|
||||
"07eb4985a1": "Double-tap {{value0}}"
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
|
|
|
|||
|
|
@ -2,13 +2,16 @@ import {
|
|||
keybindingMatchesAction,
|
||||
type KeybindingActionId,
|
||||
type KeybindingMatchOptions,
|
||||
type KeybindingOverrides
|
||||
type KeybindingOverrides,
|
||||
type PhysicalModifierToken
|
||||
} from '../../../shared/keybindings'
|
||||
|
||||
type FloatingWorkspaceShortcutEvent = Pick<
|
||||
KeyboardEvent,
|
||||
'altKey' | 'code' | 'ctrlKey' | 'key' | 'metaKey' | 'shiftKey' | 'target'
|
||||
>
|
||||
// Partial<> on the key/modifier fields so a synthetic double-tap input (which
|
||||
// carries no key/modifier flags) satisfies this shape; target stays required.
|
||||
type FloatingWorkspaceShortcutEvent = Partial<
|
||||
Pick<KeyboardEvent, 'altKey' | 'code' | 'ctrlKey' | 'key' | 'metaKey' | 'shiftKey'>
|
||||
> &
|
||||
Pick<KeyboardEvent, 'target'> & { doubleTapModifier?: PhysicalModifierToken }
|
||||
|
||||
const FLOATING_WORKSPACE_SHORTCUT_SURFACE_SELECTOR = '[data-floating-terminal-shortcut-surface]'
|
||||
const FLOATING_WORKSPACE_PANEL_SHORTCUT_ACTIONS: readonly KeybindingActionId[] = [
|
||||
|
|
|
|||
|
|
@ -282,6 +282,17 @@ describe('isFloatingWorkspacePanelShortcut', () => {
|
|||
).toBe(false)
|
||||
})
|
||||
|
||||
it('claims customized double-tap shortcuts for the floating panel surface', () => {
|
||||
const event = shortcutSurfaceEvent({}) as KeyboardEvent & { doubleTapModifier: 'Shift' }
|
||||
event.doubleTapModifier = 'Shift'
|
||||
|
||||
expect(
|
||||
isFloatingWorkspacePanelShortcut(event, 'linux', null, {
|
||||
'tab.newTerminal': ['DoubleTap+Shift']
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not claim shortcuts with Alt or the wrong platform modifier', () => {
|
||||
expect(
|
||||
isFloatingWorkspacePanelShortcut(shortcutSurfaceEvent({ key: 't', metaKey: true }), false)
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ import {
|
|||
agentTabActionId,
|
||||
getKeybindingDefinition,
|
||||
findKeybindingConflicts,
|
||||
formatKeybinding,
|
||||
formatKeybindingList,
|
||||
getEffectiveKeybindingsForAction,
|
||||
isDoubleTapBinding,
|
||||
keybindingFromInput,
|
||||
keybindingFromInputForAction,
|
||||
keybindingMatchesAction,
|
||||
|
|
@ -36,6 +38,34 @@ describe('keybindings', () => {
|
|||
expect(normalizeKeybinding('Ctrl+Nope')).toMatchObject({ ok: false })
|
||||
})
|
||||
|
||||
it('parses, normalizes, and rejects double-tap modifier bindings', () => {
|
||||
expect(normalizeKeybinding('DoubleTap+Shift')).toEqual({ ok: true, value: 'DoubleTap+Shift' })
|
||||
expect(normalizeKeybinding(' doubletap + shift ')).toEqual({
|
||||
ok: true,
|
||||
value: 'DoubleTap+Shift'
|
||||
})
|
||||
expect(normalizeKeybinding('DoubleTap+Mod')).toEqual({ ok: true, value: 'DoubleTap+Mod' })
|
||||
expect(normalizeKeybinding('DoubleTap+Cmd')).toEqual({ ok: true, value: 'DoubleTap+Cmd' })
|
||||
expect(normalizeKeybinding('DoubleTap+Alt')).toEqual({ ok: true, value: 'DoubleTap+Alt' })
|
||||
expect(normalizeKeybinding('DoubleTap+Ctrl')).toEqual({ ok: true, value: 'DoubleTap+Ctrl' })
|
||||
|
||||
// A key after DoubleTap is invalid.
|
||||
expect(normalizeKeybinding('DoubleTap+Shift+P')).toMatchObject({ ok: false })
|
||||
// Two modifiers is invalid.
|
||||
expect(normalizeKeybinding('DoubleTap+Shift+Alt')).toMatchObject({ ok: false })
|
||||
// Mod + platform-specific reuses the shared error.
|
||||
expect(normalizeKeybinding('DoubleTap+Mod+Cmd')).toEqual({
|
||||
ok: false,
|
||||
error: 'Use either Mod or a platform-specific modifier, not both.'
|
||||
})
|
||||
// Bare DoubleTap is invalid.
|
||||
expect(normalizeKeybinding('DoubleTap')).toMatchObject({ ok: false })
|
||||
|
||||
expect(isDoubleTapBinding('DoubleTap+Shift')).toBe(true)
|
||||
expect(isDoubleTapBinding('Mod+P')).toBe(false)
|
||||
expect(isDoubleTapBinding('not-a-binding')).toBe(false)
|
||||
})
|
||||
|
||||
it('allows safe bare keys only for scoped actions that opt in', () => {
|
||||
expect(normalizeKeybinding('Delete')).toMatchObject({ ok: false })
|
||||
expect(normalizeKeybindingListForAction('fileExplorer.delete', 'Delete')).toEqual(['Delete'])
|
||||
|
|
@ -813,6 +843,95 @@ describe('keybindings', () => {
|
|||
).toBe(true)
|
||||
})
|
||||
|
||||
it('matches double-tap bindings only against synthetic double-tap input', () => {
|
||||
expect(
|
||||
keybindingMatchesInput('DoubleTap+Shift', { doubleTapModifier: 'Shift' }, 'darwin')
|
||||
).toBe(true)
|
||||
// Mod resolves per platform: meta on macOS, control elsewhere.
|
||||
expect(keybindingMatchesInput('DoubleTap+Mod', { doubleTapModifier: 'Cmd' }, 'darwin')).toBe(
|
||||
true
|
||||
)
|
||||
expect(keybindingMatchesInput('DoubleTap+Mod', { doubleTapModifier: 'Ctrl' }, 'win32')).toBe(
|
||||
true
|
||||
)
|
||||
expect(keybindingMatchesInput('DoubleTap+Mod', { doubleTapModifier: 'Cmd' }, 'win32')).toBe(
|
||||
false
|
||||
)
|
||||
expect(keybindingMatchesInput('DoubleTap+Mod', { doubleTapModifier: 'Ctrl' }, 'darwin')).toBe(
|
||||
false
|
||||
)
|
||||
expect(keybindingMatchesInput('DoubleTap+Shift', { doubleTapModifier: 'Alt' }, 'darwin')).toBe(
|
||||
false
|
||||
)
|
||||
|
||||
// Cross-type negatives: a double-tap binding never matches a normal keydown,
|
||||
// and a normal binding never matches a synthetic double-tap input.
|
||||
expect(
|
||||
keybindingMatchesInput('DoubleTap+Shift', { key: 'A', code: 'KeyA', shift: true }, 'darwin')
|
||||
).toBe(false)
|
||||
expect(keybindingMatchesInput('Mod+P', { doubleTapModifier: 'Cmd' }, 'darwin')).toBe(false)
|
||||
|
||||
// Action-level matching works through user overrides, for free.
|
||||
expect(
|
||||
keybindingMatchesAction('worktree.quickOpen', { doubleTapModifier: 'Shift' }, 'darwin', {
|
||||
'worktree.quickOpen': ['DoubleTap+Shift']
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
keybindingMatchesAction('worktree.quickOpen', { doubleTapModifier: 'Alt' }, 'darwin', {
|
||||
'worktree.quickOpen': ['DoubleTap+Shift']
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('captures double-tap gestures into canonical bindings', () => {
|
||||
expect(keybindingFromInput({ doubleTapModifier: 'Shift' }, 'darwin')).toEqual({
|
||||
ok: true,
|
||||
value: 'DoubleTap+Shift'
|
||||
})
|
||||
// The platform primary modifier canonicalizes to Mod, matching normal capture.
|
||||
expect(keybindingFromInput({ doubleTapModifier: 'Cmd' }, 'darwin')).toEqual({
|
||||
ok: true,
|
||||
value: 'DoubleTap+Mod'
|
||||
})
|
||||
expect(keybindingFromInput({ doubleTapModifier: 'Ctrl' }, 'win32')).toEqual({
|
||||
ok: true,
|
||||
value: 'DoubleTap+Mod'
|
||||
})
|
||||
// A non-primary modifier keeps its explicit token.
|
||||
expect(keybindingFromInput({ doubleTapModifier: 'Ctrl' }, 'darwin')).toEqual({
|
||||
ok: true,
|
||||
value: 'DoubleTap+Ctrl'
|
||||
})
|
||||
expect(keybindingFromInput({ doubleTapModifier: 'Alt' }, 'linux')).toEqual({
|
||||
ok: true,
|
||||
value: 'DoubleTap+Alt'
|
||||
})
|
||||
// Ctrl is the primary modifier on Linux too, so it canonicalizes to Mod.
|
||||
expect(keybindingFromInput({ doubleTapModifier: 'Ctrl' }, 'linux')).toEqual({
|
||||
ok: true,
|
||||
value: 'DoubleTap+Mod'
|
||||
})
|
||||
// Cmd is not the primary modifier off-mac, so it stays explicit.
|
||||
expect(keybindingFromInput({ doubleTapModifier: 'Cmd' }, 'linux')).toEqual({
|
||||
ok: true,
|
||||
value: 'DoubleTap+Cmd'
|
||||
})
|
||||
})
|
||||
|
||||
it('formats double-tap bindings as the modifier glyph twice', () => {
|
||||
expect(formatKeybinding('DoubleTap+Shift', 'darwin')).toEqual(['⇧', '⇧'])
|
||||
expect(formatKeybinding('DoubleTap+Shift', 'linux')).toEqual(['Shift', 'Shift'])
|
||||
expect(formatKeybinding('DoubleTap+Mod', 'darwin')).toEqual(['⌘', '⌘'])
|
||||
expect(formatKeybinding('DoubleTap+Mod', 'win32')).toEqual(['Ctrl', 'Ctrl'])
|
||||
expect(formatKeybinding('DoubleTap+Cmd', 'win32')).toEqual(['Cmd', 'Cmd'])
|
||||
expect(formatKeybinding('DoubleTap+Alt', 'darwin')).toEqual(['⌥', '⌥'])
|
||||
// Ctrl's glyph ⌃ diverges from Mod's ⌘ on Mac, so cover it explicitly.
|
||||
expect(formatKeybinding('DoubleTap+Ctrl', 'darwin')).toEqual(['⌃', '⌃'])
|
||||
expect(formatKeybindingList(['DoubleTap+Shift'], 'darwin')).toBe('⇧ ⇧')
|
||||
expect(formatKeybindingList(['DoubleTap+Shift'], 'linux')).toBe('Shift Shift')
|
||||
})
|
||||
|
||||
it('matches macOS Option-composed bracket shortcuts for all-type tab switching', () => {
|
||||
const macOptionLeftBracket = {
|
||||
key: '\u201c',
|
||||
|
|
@ -840,4 +959,52 @@ describe('keybindings', () => {
|
|||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('reports conflicts across two double-tap bindings', () => {
|
||||
// Both actions share the same DoubleTap+Shift binding via overrides, so both
|
||||
// are in customizedActions and the conflict detector must flag them.
|
||||
const conflicts = findKeybindingConflicts('darwin', {
|
||||
'worktree.quickOpen': ['DoubleTap+Shift'],
|
||||
'view.tasks': ['DoubleTap+Shift']
|
||||
})
|
||||
expect(conflicts).toContainEqual({
|
||||
binding: 'DoubleTap+Shift',
|
||||
actionIds: expect.arrayContaining(['worktree.quickOpen', 'view.tasks'])
|
||||
})
|
||||
})
|
||||
|
||||
it('reports conflicts across platform-primary double-tap aliases', () => {
|
||||
expect(
|
||||
findKeybindingConflicts('darwin', {
|
||||
'worktree.quickOpen': ['DoubleTap+Mod'],
|
||||
'view.tasks': ['DoubleTap+Cmd']
|
||||
})
|
||||
).toContainEqual({
|
||||
binding: 'DoubleTap+Mod',
|
||||
actionIds: expect.arrayContaining(['worktree.quickOpen', 'view.tasks'])
|
||||
})
|
||||
|
||||
expect(
|
||||
findKeybindingConflicts('linux', {
|
||||
'worktree.quickOpen': ['DoubleTap+Mod'],
|
||||
'view.tasks': ['DoubleTap+Ctrl']
|
||||
})
|
||||
).toContainEqual({
|
||||
binding: 'DoubleTap+Mod',
|
||||
actionIds: expect.arrayContaining(['worktree.quickOpen', 'view.tasks'])
|
||||
})
|
||||
})
|
||||
|
||||
it('does not report a conflict when one action lists double-tap aliases for itself', () => {
|
||||
expect(
|
||||
findKeybindingConflicts('darwin', {
|
||||
'worktree.quickOpen': ['DoubleTap+Mod', 'DoubleTap+Cmd']
|
||||
})
|
||||
).toEqual([])
|
||||
expect(
|
||||
findKeybindingConflicts('linux', {
|
||||
'worktree.quickOpen': ['DoubleTap+Mod', 'DoubleTap+Ctrl']
|
||||
})
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -138,6 +138,9 @@ export type KeybindingDefinition = {
|
|||
conflictGroup?: string
|
||||
}
|
||||
|
||||
export type ModifierToken = 'Mod' | 'Cmd' | 'Ctrl' | 'Alt' | 'Shift'
|
||||
export type PhysicalModifierToken = Exclude<ModifierToken, 'Mod'>
|
||||
|
||||
export type KeybindingInput = {
|
||||
key?: string
|
||||
code?: string
|
||||
|
|
@ -149,6 +152,8 @@ export type KeybindingInput = {
|
|||
metaKey?: boolean
|
||||
ctrlKey?: boolean
|
||||
shiftKey?: boolean
|
||||
// Set only by the double-tap detector; always a physical token (never 'Mod').
|
||||
doubleTapModifier?: PhysicalModifierToken
|
||||
}
|
||||
|
||||
type ParsedKeybinding = {
|
||||
|
|
@ -158,6 +163,7 @@ type ParsedKeybinding = {
|
|||
alt: boolean
|
||||
shift: boolean
|
||||
key: string
|
||||
doubleTapModifier?: ModifierToken
|
||||
}
|
||||
|
||||
type NormalizeKeybindingOptions = {
|
||||
|
|
@ -982,6 +988,84 @@ function normalizeKeyToken(token: string): string | null {
|
|||
return simple[upper] ?? null
|
||||
}
|
||||
|
||||
function parseModifierToken(rawPart: string): ModifierToken | null {
|
||||
const part = rawPart.toLowerCase()
|
||||
if (part === 'mod' || part === 'cmdorctrl' || part === 'commandorcontrol') {
|
||||
return 'Mod'
|
||||
}
|
||||
if (part === 'cmd' || part === 'command' || part === 'meta' || rawPart === '⌘') {
|
||||
return 'Cmd'
|
||||
}
|
||||
if (part === 'ctrl' || part === 'control' || rawPart === '⌃') {
|
||||
return 'Ctrl'
|
||||
}
|
||||
if (part === 'alt' || part === 'option' || part === 'opt' || rawPart === '⌥') {
|
||||
return 'Alt'
|
||||
}
|
||||
if (part === 'shift' || rawPart === '⇧') {
|
||||
return 'Shift'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function applyModifierToken(parsed: ParsedKeybinding, modifier: ModifierToken): void {
|
||||
if (modifier === 'Mod') {
|
||||
parsed.mod = true
|
||||
} else if (modifier === 'Cmd') {
|
||||
parsed.meta = true
|
||||
} else if (modifier === 'Ctrl') {
|
||||
parsed.control = true
|
||||
} else if (modifier === 'Alt') {
|
||||
parsed.alt = true
|
||||
} else {
|
||||
parsed.shift = true
|
||||
}
|
||||
}
|
||||
|
||||
function emptyParsedKeybinding(): ParsedKeybinding {
|
||||
return { mod: false, meta: false, control: false, alt: false, shift: false, key: '' }
|
||||
}
|
||||
|
||||
// Why: a double-tap is a bare modifier with no key, so it cannot reuse the
|
||||
// normal "one key required" parse path; validation of conflicting/extra
|
||||
// modifiers is deferred to normalizeKeybindingWithOptions for shared errors.
|
||||
function parseDoubleTapKeybinding(rawParts: string[]): ParsedKeybinding | null {
|
||||
const modifiers: ModifierToken[] = []
|
||||
let sawDoubleTap = false
|
||||
for (const rawPart of rawParts) {
|
||||
if (rawPart.toLowerCase() === 'doubletap') {
|
||||
if (sawDoubleTap) {
|
||||
return null
|
||||
}
|
||||
sawDoubleTap = true
|
||||
continue
|
||||
}
|
||||
const modifier = parseModifierToken(rawPart)
|
||||
if (!modifier) {
|
||||
return null
|
||||
}
|
||||
modifiers.push(modifier)
|
||||
}
|
||||
if (modifiers.length === 0) {
|
||||
return null
|
||||
}
|
||||
const parsed = emptyParsedKeybinding()
|
||||
for (const modifier of modifiers) {
|
||||
applyModifierToken(parsed, modifier)
|
||||
}
|
||||
// Mod combined with a platform-specific modifier: keep both flags so normalize
|
||||
// emits the shared "Mod or platform-specific, not both" error.
|
||||
if (parsed.mod && (parsed.meta || parsed.control)) {
|
||||
parsed.doubleTapModifier = 'Mod'
|
||||
return parsed
|
||||
}
|
||||
if (modifiers.length > 1) {
|
||||
return null
|
||||
}
|
||||
parsed.doubleTapModifier = modifiers[0]
|
||||
return parsed
|
||||
}
|
||||
|
||||
function parseKeybinding(binding: string): ParsedKeybinding | null {
|
||||
const rawParts = binding
|
||||
.split('+')
|
||||
|
|
@ -991,35 +1075,15 @@ function parseKeybinding(binding: string): ParsedKeybinding | null {
|
|||
return null
|
||||
}
|
||||
|
||||
const parsed: ParsedKeybinding = {
|
||||
mod: false,
|
||||
meta: false,
|
||||
control: false,
|
||||
alt: false,
|
||||
shift: false,
|
||||
key: ''
|
||||
if (rawParts.some((part) => part.toLowerCase() === 'doubletap')) {
|
||||
return parseDoubleTapKeybinding(rawParts)
|
||||
}
|
||||
|
||||
const parsed = emptyParsedKeybinding()
|
||||
for (const rawPart of rawParts) {
|
||||
const part = rawPart.toLowerCase()
|
||||
if (part === 'mod' || part === 'cmdorctrl' || part === 'commandorcontrol') {
|
||||
parsed.mod = true
|
||||
continue
|
||||
}
|
||||
if (part === 'cmd' || part === 'command' || part === 'meta' || rawPart === '⌘') {
|
||||
parsed.meta = true
|
||||
continue
|
||||
}
|
||||
if (part === 'ctrl' || part === 'control' || rawPart === '⌃') {
|
||||
parsed.control = true
|
||||
continue
|
||||
}
|
||||
if (part === 'alt' || part === 'option' || part === 'opt' || rawPart === '⌥') {
|
||||
parsed.alt = true
|
||||
continue
|
||||
}
|
||||
if (part === 'shift' || rawPart === '⇧') {
|
||||
parsed.shift = true
|
||||
const modifier = parseModifierToken(rawPart)
|
||||
if (modifier) {
|
||||
applyModifierToken(parsed, modifier)
|
||||
continue
|
||||
}
|
||||
if (parsed.key) {
|
||||
|
|
@ -1036,6 +1100,9 @@ function parseKeybinding(binding: string): ParsedKeybinding | null {
|
|||
}
|
||||
|
||||
function canonicalizeParsedKeybinding(parsed: ParsedKeybinding): string {
|
||||
if (parsed.doubleTapModifier) {
|
||||
return `DoubleTap+${parsed.doubleTapModifier}`
|
||||
}
|
||||
const parts: string[] = []
|
||||
if (parsed.mod) {
|
||||
parts.push('Mod')
|
||||
|
|
@ -1086,6 +1153,9 @@ function normalizeKeybindingWithOptions(
|
|||
if (parsed.mod && (parsed.meta || parsed.control)) {
|
||||
return { ok: false, error: 'Use either Mod or a platform-specific modifier, not both.' }
|
||||
}
|
||||
if (parsed.doubleTapModifier) {
|
||||
return { ok: true, value: canonicalizeParsedKeybinding(parsed) }
|
||||
}
|
||||
const isShiftInsert = parsed.shift && parsed.key === 'Insert'
|
||||
const isBareAllowed = options.allowBareKeybindings === true && isSafeBareKey(parsed)
|
||||
if (
|
||||
|
|
@ -1105,6 +1175,10 @@ export function normalizeKeybinding(binding: string): KeybindingValidationResult
|
|||
return normalizeKeybindingWithOptions(binding)
|
||||
}
|
||||
|
||||
export function isDoubleTapBinding(binding: string): boolean {
|
||||
return Boolean(parseKeybinding(binding)?.doubleTapModifier)
|
||||
}
|
||||
|
||||
function normalizeKeybindingListWithOptions(
|
||||
input: string,
|
||||
options: NormalizeKeybindingOptions = {}
|
||||
|
|
@ -1291,11 +1365,33 @@ function keyTokenFromInput(input: KeybindingInput, platform: NodeJS.Platform): s
|
|||
return physicalCodeKeyTokenFromInput(input)
|
||||
}
|
||||
|
||||
// Why: the platform primary modifier canonicalizes to Mod, mirroring normal
|
||||
// capture where Cmd on macOS / Ctrl elsewhere both become Mod.
|
||||
function canonicalDoubleTapToken(
|
||||
modifier: PhysicalModifierToken,
|
||||
platform: NodeJS.Platform
|
||||
): ModifierToken {
|
||||
const isMac = platform === 'darwin'
|
||||
if (modifier === 'Cmd' && isMac) {
|
||||
return 'Mod'
|
||||
}
|
||||
if (modifier === 'Ctrl' && !isMac) {
|
||||
return 'Mod'
|
||||
}
|
||||
return modifier
|
||||
}
|
||||
|
||||
function keybindingFromInputWithOptions(
|
||||
input: KeybindingInput,
|
||||
platform: NodeJS.Platform,
|
||||
options: NormalizeKeybindingOptions = {}
|
||||
): KeybindingValidationResult {
|
||||
if (input.doubleTapModifier) {
|
||||
return normalizeKeybindingWithOptions(
|
||||
`DoubleTap+${canonicalDoubleTapToken(input.doubleTapModifier, platform)}`,
|
||||
options
|
||||
)
|
||||
}
|
||||
const key = keyTokenFromInput(input, platform)
|
||||
if (!key) {
|
||||
return { ok: false, error: 'Press a key, not only a modifier.' }
|
||||
|
|
@ -1564,6 +1660,24 @@ function keyMatches(
|
|||
return canUsePhysicalCodeFallback(input) && physicalCodeKeyTokenFromInput(input) === parsedKey
|
||||
}
|
||||
|
||||
function resolveModifierToken(
|
||||
modifier: ModifierToken,
|
||||
platform: NodeJS.Platform
|
||||
): 'meta' | 'control' | 'alt' | 'shift' {
|
||||
switch (modifier) {
|
||||
case 'Mod':
|
||||
return platform === 'darwin' ? 'meta' : 'control'
|
||||
case 'Cmd':
|
||||
return 'meta'
|
||||
case 'Ctrl':
|
||||
return 'control'
|
||||
case 'Alt':
|
||||
return 'alt'
|
||||
case 'Shift':
|
||||
return 'shift'
|
||||
}
|
||||
}
|
||||
|
||||
export function keybindingMatchesInput(
|
||||
binding: string,
|
||||
input: KeybindingInput,
|
||||
|
|
@ -1573,11 +1687,31 @@ export function keybindingMatchesInput(
|
|||
if (!parsed) {
|
||||
return false
|
||||
}
|
||||
// A double-tap binding matches only a synthetic double-tap input, resolved per
|
||||
// platform; a normal binding never matches a synthetic input, and vice-versa.
|
||||
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)
|
||||
)
|
||||
}
|
||||
|
||||
function keybindingConflictIdentity(binding: string, platform: NodeJS.Platform): string {
|
||||
const parsed = parseKeybinding(binding)
|
||||
if (!parsed?.doubleTapModifier) {
|
||||
return binding
|
||||
}
|
||||
return `DoubleTap:${resolveModifierToken(parsed.doubleTapModifier, platform)}`
|
||||
}
|
||||
|
||||
export function keybindingMatchesAction(
|
||||
actionId: KeybindingActionId,
|
||||
input: KeybindingInput,
|
||||
|
|
@ -1597,12 +1731,31 @@ export function keybindingMatchesAction(
|
|||
)
|
||||
}
|
||||
|
||||
function formatModifierGlyph(modifier: ModifierToken, isMac: boolean): string {
|
||||
switch (modifier) {
|
||||
case 'Mod':
|
||||
return isMac ? '⌘' : 'Ctrl'
|
||||
case 'Cmd':
|
||||
return isMac ? '⌘' : 'Cmd'
|
||||
case 'Ctrl':
|
||||
return isMac ? '⌃' : 'Ctrl'
|
||||
case 'Alt':
|
||||
return isMac ? '⌥' : 'Alt'
|
||||
case 'Shift':
|
||||
return isMac ? '⇧' : 'Shift'
|
||||
}
|
||||
}
|
||||
|
||||
export function formatKeybinding(binding: string, platform: NodeJS.Platform): string[] {
|
||||
const parsed = parseKeybinding(binding)
|
||||
if (!parsed) {
|
||||
return [binding]
|
||||
}
|
||||
const isMac = platform === 'darwin'
|
||||
if (parsed.doubleTapModifier) {
|
||||
const glyph = formatModifierGlyph(parsed.doubleTapModifier, isMac)
|
||||
return [glyph, glyph]
|
||||
}
|
||||
const parts: string[] = []
|
||||
if (parsed.mod) {
|
||||
parts.push(isMac ? '⌘' : 'Ctrl')
|
||||
|
|
@ -1631,7 +1784,10 @@ export function formatKeybindingList(
|
|||
return 'Unassigned'
|
||||
}
|
||||
return bindings
|
||||
.map((binding) => formatKeybinding(binding, platform).join(platform === 'darwin' ? '' : '+'))
|
||||
.map((binding) => {
|
||||
const separator = isDoubleTapBinding(binding) ? ' ' : platform === 'darwin' ? '' : '+'
|
||||
return formatKeybinding(binding, platform).join(separator)
|
||||
})
|
||||
.join(', ')
|
||||
}
|
||||
|
||||
|
|
@ -1674,7 +1830,7 @@ export function findKeybindingConflicts(
|
|||
overrides?: KeybindingOverrides,
|
||||
options: FindKeybindingConflictOptions = {}
|
||||
): KeybindingConflict[] {
|
||||
const owners = new Map<string, KeybindingActionId[]>()
|
||||
const owners = new Map<string, { binding: string; actionIds: Set<KeybindingActionId> }>()
|
||||
const ignoredActionIds = new Set(options.ignoredActionIds ?? [])
|
||||
const customizedActions = new Set(
|
||||
Object.keys(overrides ?? {}).filter(
|
||||
|
|
@ -1694,21 +1850,27 @@ export function findKeybindingConflicts(
|
|||
groups.add(definition.scope)
|
||||
}
|
||||
for (const group of groups) {
|
||||
const conflictKey = `${group}\u0000${binding}`
|
||||
const current = owners.get(conflictKey) ?? []
|
||||
current.push(definition.id)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(owners.entries())
|
||||
.filter(
|
||||
([, actionIds]) =>
|
||||
actionIds.length > 1 && actionIds.some((actionId) => customizedActions.has(actionId))
|
||||
)
|
||||
.map(([conflictKey, actionIds]) => ({
|
||||
binding: conflictKey.slice(conflictKey.indexOf('\u0000') + 1),
|
||||
actionIds
|
||||
return Array.from(owners.values())
|
||||
.filter(({ actionIds }) => actionIds.size > 1 && setIntersects(actionIds, customizedActions))
|
||||
.map(({ binding, actionIds }) => ({
|
||||
binding,
|
||||
actionIds: Array.from(actionIds)
|
||||
}))
|
||||
}
|
||||
|
||||
function setIntersects<T>(left: ReadonlySet<T>, right: ReadonlySet<T>): boolean {
|
||||
for (const value of left) {
|
||||
if (right.has(value)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,135 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
ModifierDoubleTapDetector,
|
||||
modifierFromKeyEvent,
|
||||
toModifierDoubleTapEvent,
|
||||
type ModifierDoubleTapEvent
|
||||
} from './modifier-double-tap-detector'
|
||||
|
||||
function down(
|
||||
modifier: ModifierDoubleTapEvent['modifier'],
|
||||
overrides: Partial<ModifierDoubleTapEvent> = {}
|
||||
): ModifierDoubleTapEvent {
|
||||
return { type: 'keyDown', modifier, isModifierOnly: true, isAutoRepeat: false, ...overrides }
|
||||
}
|
||||
|
||||
function up(
|
||||
modifier: ModifierDoubleTapEvent['modifier'],
|
||||
overrides: Partial<ModifierDoubleTapEvent> = {}
|
||||
): ModifierDoubleTapEvent {
|
||||
return { type: 'keyUp', modifier, isModifierOnly: true, isAutoRepeat: false, ...overrides }
|
||||
}
|
||||
|
||||
const otherKey: ModifierDoubleTapEvent = {
|
||||
type: 'keyDown',
|
||||
modifier: null,
|
||||
isModifierOnly: false,
|
||||
isAutoRepeat: false
|
||||
}
|
||||
|
||||
describe('ModifierDoubleTapDetector', () => {
|
||||
it('emits when the second press lands inside the window', () => {
|
||||
const d = new ModifierDoubleTapDetector()
|
||||
expect(d.process(down('Shift'), 0)).toBeNull()
|
||||
expect(d.process(up('Shift'), 10)).toBeNull()
|
||||
expect(d.process(down('Shift'), 200)).toEqual({ modifier: 'Shift' })
|
||||
})
|
||||
|
||||
it('does not emit when the second press is past the window', () => {
|
||||
const d = new ModifierDoubleTapDetector()
|
||||
d.process(down('Shift'), 0)
|
||||
d.process(up('Shift'), 10)
|
||||
expect(d.process(down('Shift'), 400)).toBeNull()
|
||||
})
|
||||
|
||||
it('resets on an intervening non-modifier key', () => {
|
||||
const d = new ModifierDoubleTapDetector()
|
||||
d.process(down('Shift'), 0)
|
||||
d.process(up('Shift'), 10)
|
||||
expect(d.process(otherKey, 20)).toBeNull()
|
||||
expect(d.process(down('Shift'), 100)).toBeNull()
|
||||
})
|
||||
|
||||
it('treats a different modifier as a fresh gesture, not a completion', () => {
|
||||
const d = new ModifierDoubleTapDetector()
|
||||
d.process(down('Shift'), 0)
|
||||
d.process(up('Shift'), 10)
|
||||
// Wrong modifier: no emit, but it begins a new first tap.
|
||||
expect(d.process(down('Alt'), 100)).toBeNull()
|
||||
expect(d.process(up('Alt'), 110)).toBeNull()
|
||||
expect(d.process(down('Alt'), 150)).toEqual({ modifier: 'Alt' })
|
||||
})
|
||||
|
||||
it('does not treat an auto-repeat hold as a tap', () => {
|
||||
const d = new ModifierDoubleTapDetector()
|
||||
d.process(down('Shift'), 0)
|
||||
// Holding the key emits auto-repeat keyDowns — this must cancel the gesture.
|
||||
expect(d.process(down('Shift', { isAutoRepeat: true }), 30)).toBeNull()
|
||||
d.process(up('Shift'), 500)
|
||||
expect(d.process(down('Shift'), 520)).toBeNull()
|
||||
})
|
||||
|
||||
it('does not emit when another modifier is held (isModifierOnly false)', () => {
|
||||
const d = new ModifierDoubleTapDetector()
|
||||
expect(d.process(down('Shift', { isModifierOnly: false }), 0)).toBeNull()
|
||||
d.process(up('Shift'), 10)
|
||||
expect(d.process(down('Shift'), 100)).toBeNull()
|
||||
})
|
||||
|
||||
it('handles a second keyDown of the same modifier without an intervening keyUp', () => {
|
||||
const d = new ModifierDoubleTapDetector()
|
||||
d.process(down('Shift'), 0)
|
||||
// Missed keyUp — a fresh (non-repeat) keyDown for the same modifier just
|
||||
// restarts the first tap rather than emitting.
|
||||
d.process(down('Shift'), 50)
|
||||
d.process(up('Shift'), 60)
|
||||
// The next press within the window still completes the gesture.
|
||||
expect(d.process(down('Shift'), 200)).toEqual({ modifier: 'Shift' })
|
||||
})
|
||||
|
||||
it('clears armed state when the second keydown was suppressed (allowlisted path)', () => {
|
||||
const d = new ModifierDoubleTapDetector()
|
||||
d.process(down('Shift'), 0) // first tap down → down1
|
||||
d.process(up('Shift'), 10) // first tap up → armed
|
||||
// The main process suppressed the second keydown (an allowlisted action fired
|
||||
// there), but the second tap's keyup still reaches this detector.
|
||||
d.process(up('Shift'), 20)
|
||||
// A later lone Shift press (e.g. typing a capital) must NOT phantom-complete
|
||||
// a double-tap from the stale armed state.
|
||||
expect(d.process(down('Shift'), 200)).toBeNull()
|
||||
})
|
||||
|
||||
it('clears state on reset()', () => {
|
||||
const d = new ModifierDoubleTapDetector()
|
||||
d.process(down('Shift'), 0)
|
||||
d.process(up('Shift'), 10)
|
||||
d.reset()
|
||||
expect(d.process(down('Shift'), 100)).toBeNull()
|
||||
})
|
||||
|
||||
it('normalizes platform key events', () => {
|
||||
expect(modifierFromKeyEvent('ShiftLeft', 'Shift')).toBe('Shift')
|
||||
expect(modifierFromKeyEvent('MetaRight', 'Meta')).toBe('Cmd')
|
||||
expect(modifierFromKeyEvent('ControlLeft', 'Control')).toBe('Ctrl')
|
||||
expect(modifierFromKeyEvent('KeyA', 'a')).toBeNull()
|
||||
|
||||
expect(
|
||||
toModifierDoubleTapEvent({ type: 'keyDown', code: 'ShiftLeft', key: 'Shift', shift: true })
|
||||
).toEqual({ type: 'keyDown', modifier: 'Shift', isModifierOnly: true, isAutoRepeat: false })
|
||||
|
||||
// Another modifier held → not a bare modifier event.
|
||||
expect(
|
||||
toModifierDoubleTapEvent({
|
||||
type: 'keyDown',
|
||||
code: 'ShiftLeft',
|
||||
key: 'Shift',
|
||||
shift: true,
|
||||
meta: true
|
||||
})
|
||||
).toMatchObject({ modifier: 'Shift', isModifierOnly: false })
|
||||
|
||||
expect(
|
||||
toModifierDoubleTapEvent({ type: 'keyDown', code: 'KeyA', key: 'a' })
|
||||
).toMatchObject({ modifier: null, isModifierOnly: false })
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
import type { PhysicalModifierToken } from './keybindings'
|
||||
|
||||
// Why: max gap between the first release and the second press. Internal — not
|
||||
// user-configurable — and tight enough that normal fast typing never triggers.
|
||||
const DOUBLE_TAP_WINDOW_MS = 300
|
||||
|
||||
export type ModifierDoubleTapEventType = 'keyDown' | 'keyUp'
|
||||
|
||||
// A keyboard event normalized to just what the detector needs.
|
||||
export type ModifierDoubleTapEvent = {
|
||||
type: ModifierDoubleTapEventType
|
||||
// Which physical modifier this event is about, or null for any other key.
|
||||
modifier: PhysicalModifierToken | null
|
||||
// True only for a bare modifier press/release with no OTHER modifier held.
|
||||
isModifierOnly: boolean
|
||||
isAutoRepeat: boolean
|
||||
}
|
||||
|
||||
export type DetectedDoubleTap = { modifier: PhysicalModifierToken }
|
||||
|
||||
export type ModifierKeyEventLike = {
|
||||
type: ModifierDoubleTapEventType
|
||||
code?: string
|
||||
key?: string
|
||||
shift?: boolean
|
||||
control?: boolean
|
||||
alt?: boolean
|
||||
meta?: boolean
|
||||
isAutoRepeat?: boolean
|
||||
}
|
||||
|
||||
const MODIFIER_BY_CODE: Record<string, PhysicalModifierToken> = {
|
||||
ShiftLeft: 'Shift',
|
||||
ShiftRight: 'Shift',
|
||||
ControlLeft: 'Ctrl',
|
||||
ControlRight: 'Ctrl',
|
||||
AltLeft: 'Alt',
|
||||
AltRight: 'Alt',
|
||||
MetaLeft: 'Cmd',
|
||||
MetaRight: 'Cmd'
|
||||
}
|
||||
|
||||
const MODIFIER_BY_KEY: Record<string, PhysicalModifierToken> = {
|
||||
Shift: 'Shift',
|
||||
Control: 'Ctrl',
|
||||
Alt: 'Alt',
|
||||
Meta: 'Cmd'
|
||||
}
|
||||
|
||||
// Maps a physical key event to the modifier it represents, or null for any
|
||||
// non-modifier key. Detector output is always a physical token (never 'Mod').
|
||||
export function modifierFromKeyEvent(
|
||||
code: string | undefined,
|
||||
key: string | undefined
|
||||
): PhysicalModifierToken | null {
|
||||
if (code && MODIFIER_BY_CODE[code]) {
|
||||
return MODIFIER_BY_CODE[code]
|
||||
}
|
||||
return key ? (MODIFIER_BY_KEY[key] ?? null) : null
|
||||
}
|
||||
|
||||
function otherModifierHeld(event: ModifierKeyEventLike, modifier: PhysicalModifierToken): boolean {
|
||||
if (modifier !== 'Shift' && event.shift) {
|
||||
return true
|
||||
}
|
||||
if (modifier !== 'Ctrl' && event.control) {
|
||||
return true
|
||||
}
|
||||
if (modifier !== 'Alt' && event.alt) {
|
||||
return true
|
||||
}
|
||||
if (modifier !== 'Cmd' && event.meta) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Normalizes a platform key event (DOM or Electron) into the detector input.
|
||||
export function toModifierDoubleTapEvent(event: ModifierKeyEventLike): ModifierDoubleTapEvent {
|
||||
const modifier = modifierFromKeyEvent(event.code, event.key)
|
||||
return {
|
||||
type: event.type,
|
||||
modifier,
|
||||
isModifierOnly: modifier !== null && !otherModifierHeld(event, modifier),
|
||||
isAutoRepeat: Boolean(event.isAutoRepeat)
|
||||
}
|
||||
}
|
||||
|
||||
type DetectorState =
|
||||
| { phase: 'idle' }
|
||||
| { phase: 'down1'; modifier: PhysicalModifierToken }
|
||||
| { phase: 'armed'; modifier: PhysicalModifierToken; deadlineMs: number }
|
||||
|
||||
export class ModifierDoubleTapDetector {
|
||||
private state: DetectorState = { phase: 'idle' }
|
||||
|
||||
process(event: ModifierDoubleTapEvent, timestampMs: number): DetectedDoubleTap | null {
|
||||
// A non-modifier key, or a modifier chorded with another, breaks the gesture.
|
||||
// (On keyUp, isModifierOnly:false means another modifier is still held — the
|
||||
// gesture was already reset at that modifier's keyDown.)
|
||||
if (event.modifier === null || !event.isModifierOnly) {
|
||||
this.state = { phase: 'idle' }
|
||||
return null
|
||||
}
|
||||
if (event.type === 'keyUp') {
|
||||
this.onModifierUp(event.modifier, timestampMs)
|
||||
return null
|
||||
}
|
||||
return this.onModifierDown(event.modifier, event.isAutoRepeat, timestampMs)
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.state = { phase: 'idle' }
|
||||
}
|
||||
|
||||
private onModifierDown(
|
||||
modifier: PhysicalModifierToken,
|
||||
isAutoRepeat: boolean,
|
||||
timestampMs: number
|
||||
): DetectedDoubleTap | null {
|
||||
if (
|
||||
this.state.phase === 'armed' &&
|
||||
this.state.modifier === modifier &&
|
||||
!isAutoRepeat &&
|
||||
timestampMs <= this.state.deadlineMs
|
||||
) {
|
||||
this.state = { phase: 'idle' }
|
||||
return { modifier }
|
||||
}
|
||||
// Auto-repeat means the key is being held, not tapped.
|
||||
if (isAutoRepeat) {
|
||||
this.state = { phase: 'idle' }
|
||||
return null
|
||||
}
|
||||
// Any other fresh bare-modifier press (re)starts from the first tap.
|
||||
this.state = { phase: 'down1', modifier }
|
||||
return null
|
||||
}
|
||||
|
||||
private onModifierUp(modifier: PhysicalModifierToken, timestampMs: number): void {
|
||||
if (this.state.phase === 'down1' && this.state.modifier === modifier) {
|
||||
this.state = { phase: 'armed', modifier, deadlineMs: timestampMs + DOUBLE_TAP_WINDOW_MS }
|
||||
return
|
||||
}
|
||||
// Why: a keyup of the armed modifier with no intervening second keydown means
|
||||
// the second press was consumed elsewhere (the main process suppresses it for
|
||||
// an allowlisted action). Clear armed so a later lone press of the same
|
||||
// modifier can't phantom-complete a double-tap.
|
||||
if (this.state.phase === 'armed' && this.state.modifier === modifier) {
|
||||
this.state = { phase: 'idle' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -763,4 +763,21 @@ describe('resolveWindowShortcutAction', () => {
|
|||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves an allowlisted action from a synthetic double-tap input', () => {
|
||||
// (a) A synthetic DoubleTap+Shift input resolves the overridden action.
|
||||
const overrides: KeybindingOverrides = { 'worktree.quickOpen': ['DoubleTap+Shift'] }
|
||||
expect(
|
||||
resolveWindowShortcutAction({ doubleTapModifier: 'Shift' }, 'darwin', overrides)
|
||||
).toEqual({ type: 'openQuickOpen' })
|
||||
|
||||
// (b) A different modifier does not resolve it.
|
||||
expect(
|
||||
resolveWindowShortcutAction({ doubleTapModifier: 'Alt' }, 'darwin', overrides)
|
||||
).toBeNull()
|
||||
|
||||
// (c) Implicit numeric shortcuts are guarded on input.key, which a double-tap
|
||||
// input never has, so they cannot accidentally match a double-tap event.
|
||||
expect(resolveWindowShortcutAction({ doubleTapModifier: 'Cmd' }, 'darwin')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ import {
|
|||
normalizeTerminalShortcutPolicy,
|
||||
type KeybindingActionId,
|
||||
type KeybindingMatchOptions,
|
||||
type KeybindingOverrides
|
||||
type KeybindingOverrides,
|
||||
type PhysicalModifierToken
|
||||
} from './keybindings'
|
||||
|
||||
export type WindowShortcutInput = {
|
||||
|
|
@ -21,6 +22,9 @@ export type WindowShortcutInput = {
|
|||
metaKey?: boolean
|
||||
ctrlKey?: boolean
|
||||
shiftKey?: boolean
|
||||
// Set only by the double-tap detector; threads the synthetic input through
|
||||
// the main-process resolver so allowlisted actions can fire on double-tap.
|
||||
doubleTapModifier?: PhysicalModifierToken
|
||||
}
|
||||
|
||||
export type WindowShortcutAction =
|
||||
|
|
|
|||
Loading…
Reference in New Issue