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:
Devin345458
2026-06-17 18:25:27 -07:00
committed by GitHub
co-authored by Orca Devin345458 Jinwoo-H
parent 0eec0ad74a
commit 1368ba84b8
34 changed files with 2199 additions and 457 deletions
+114 -30
View File
@@ -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,
+11 -10
View File
@@ -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"
+31 -9
View File
@@ -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)
}
+5 -1
View File
@@ -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": {
+5 -1
View File
@@ -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": {
+5 -1
View File
@@ -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": {
+5 -1
View File
@@ -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": {
+5 -1
View File
@@ -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)