mirror of
https://github.com/stablyai/orca.git
synced 2026-10-28 00:02:54 +00:00
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:
co-authored by
Orca
Devin345458
Jinwoo-H
parent
0eec0ad74a
commit
1368ba84b8
+114
-30
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user