mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
Custom tab shortcuts work right after a keyboard worktree switch (#30941)
* fix(shortcuts): app shortcuts run before the terminal, whatever order they mounted in App shortcut owners and terminal panes both listened on window capture, so React's effect order decided who saw a chord first. After a worktree switch the new pane registered before the tab shortcut listener and turned a user-bound Cmd+Right into Ctrl+E; clicking a tab flipped the order back. A keyboard router installed before React renders now runs every app owner, in registration order, ahead of any pane listener; a key no owner claims still reaches the terminal's own handling. Held tab chords are claimed on every repeat but switch once. Tracked in STA-11205 (same bug as STA-4602). * test(shortcuts): type the router mock with key event names * fix(shortcuts): install the keyboard router in the web client before it renders * test(shortcuts): floating panel specs read the routed key listeners from the window stub
This commit is contained in:
@@ -40,6 +40,7 @@ import {
|
||||
} from './app-command-handlers'
|
||||
import type { AppChromeLayout } from './use-app-chrome-layout'
|
||||
import type { FloatingWorkspacePanelState } from './use-floating-workspace-panel'
|
||||
import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router'
|
||||
|
||||
/**
|
||||
* Registers the window-level shortcut listeners and the app command dispatcher.
|
||||
@@ -104,7 +105,7 @@ export function useGlobalKeybindings(args: {
|
||||
terminalShortcutPolicy
|
||||
} = state
|
||||
|
||||
// Child handlers (e.g. terminal search) share this window capture phase and fire first; bail if they already preventDefault'd so both don't act.
|
||||
// Other app owners share this routed stage and may fire first; bail if they already preventDefault'd so both don't act.
|
||||
if (input.defaultPrevented || input.isComposing || input.key === 'Process') {
|
||||
return
|
||||
}
|
||||
@@ -322,13 +323,13 @@ export function useGlobalKeybindings(args: {
|
||||
// Why: a window blur mid-gesture must not leave the detector armed.
|
||||
const onBlur = (): void => doubleTapDetector.reset()
|
||||
|
||||
window.addEventListener('keydown', onKeyDown, { capture: true })
|
||||
window.addEventListener('keyup', onKeyUp, { capture: true })
|
||||
addAppKeyListener('keydown', onKeyDown)
|
||||
addAppKeyListener('keyup', onKeyUp)
|
||||
window.addEventListener('blur', onBlur)
|
||||
return () => {
|
||||
unregisterAppCommandDispatcher()
|
||||
window.removeEventListener('keydown', onKeyDown, { capture: true })
|
||||
window.removeEventListener('keyup', onKeyUp, { capture: true })
|
||||
removeAppKeyListener('keydown', onKeyDown)
|
||||
removeAppKeyListener('keyup', onKeyUp)
|
||||
window.removeEventListener('blur', onBlur)
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -28,6 +28,7 @@ import { translate } from '@/i18n/i18n'
|
||||
import { getWorkspaceComposerInitialFocusTarget } from '@/lib/workspace-composer-initial-focus'
|
||||
import { getFolderWorkspacePrimaryActionLabel } from '@/components/sidebar/folder-workspace-composer-helpers'
|
||||
import type { WorkspaceCreationTargetHint } from '@/lib/project-host-workspace-target'
|
||||
import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router'
|
||||
|
||||
// Why: match App-level AddRepoDialog loading — the add flow is off the hot
|
||||
// path for the composer, so keep its clone/SSH machinery out of the entry render.
|
||||
@@ -284,8 +285,8 @@ function QuickTabBody({
|
||||
event.preventDefault()
|
||||
void handleCreate()
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown, { capture: true })
|
||||
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
|
||||
addAppKeyListener('keydown', onKeyDown)
|
||||
return () => removeAppKeyListener('keydown', onKeyDown)
|
||||
}, [active, composerRef, createDisabled, handleCreate, nestedDialogOpen])
|
||||
|
||||
return (
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom'
|
||||
import { Copy } from 'lucide-react'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { addViewportSizeChangeListener } from '@/hooks/viewport-size-change-listener'
|
||||
import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router'
|
||||
|
||||
type SelectedTextCopyMenuProps = {
|
||||
children: React.ReactNode
|
||||
@@ -57,14 +58,14 @@ export function SelectedTextCopyMenu({
|
||||
}
|
||||
|
||||
window.addEventListener('pointerdown', close)
|
||||
window.addEventListener('keydown', handleKeyDown, true)
|
||||
addAppKeyListener('keydown', handleKeyDown)
|
||||
window.addEventListener('scroll', close, true)
|
||||
// Why: a bare resize listener also fires on the main process's reveal reflow, which changes
|
||||
// no dimensions — the menu would close on every window restore.
|
||||
const removeViewportListener = addViewportSizeChangeListener(close)
|
||||
return () => {
|
||||
window.removeEventListener('pointerdown', close)
|
||||
window.removeEventListener('keydown', handleKeyDown, true)
|
||||
removeAppKeyListener('keydown', handleKeyDown)
|
||||
window.removeEventListener('scroll', close, true)
|
||||
removeViewportListener()
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
useActivityTerminalPortalStatus
|
||||
} from './activity-terminal-portal-status'
|
||||
import type { ActivityTerminalPortalSlotId } from './activity-thread-types'
|
||||
import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router'
|
||||
|
||||
export * from './activity-prototype-page-exports'
|
||||
|
||||
@@ -236,8 +237,8 @@ export default function ActivityPrototypePage(): React.JSX.Element {
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', focusActivityFilter, { capture: true })
|
||||
return () => window.removeEventListener('keydown', focusActivityFilter, { capture: true })
|
||||
addAppKeyListener('keydown', focusActivityFilter)
|
||||
return () => removeAppKeyListener('keydown', focusActivityFilter)
|
||||
}, [activePortalTargetEl, inactivePortalTargetEl])
|
||||
|
||||
const {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect } from 'react'
|
||||
import { hasVisibleOverlay } from '@/lib/visible-overlay'
|
||||
import type { AutomationsPageLocalState } from './use-automations-page-local-state'
|
||||
import type { AutomationsPageStoreState } from './use-automations-page-store-state'
|
||||
import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router'
|
||||
|
||||
export function useAutomationsPageEscape({
|
||||
store,
|
||||
@@ -92,8 +93,8 @@ export function useAutomationsPageEscape({
|
||||
closeAutomationsPage()
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown, { capture: true })
|
||||
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
|
||||
addAppKeyListener('keydown', onKeyDown)
|
||||
return () => removeAppKeyListener('keydown', onKeyDown)
|
||||
}, [
|
||||
activeModal,
|
||||
closeAutomationsPage,
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { isEditableKeyboardTarget } from '../host-guest/browser-keyboard'
|
||||
import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Grab mode state machine
|
||||
@@ -266,8 +267,8 @@ export function useGrabMode(browserPageId: string): GrabModeHook {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown, true)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, true)
|
||||
addAppKeyListener('keydown', handleKeyDown)
|
||||
return () => removeAppKeyListener('keydown', handleKeyDown)
|
||||
}, [state, cancel])
|
||||
|
||||
return { state, payload, error, contextMenu, toggle, cancel, rearm, exit }
|
||||
|
||||
+3
-2
@@ -13,6 +13,7 @@ import { translate } from '@/i18n/i18n'
|
||||
import { normalizeExternalBrowserUrl } from '../../../../../shared/browser-url'
|
||||
import { resolveBrowserSourceUnifiedTab } from '@/lib/browser-workspace-source-resolution'
|
||||
import type { BrowserPageContextMenuState } from '../describe-page/browser-page-types'
|
||||
import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router'
|
||||
|
||||
// `focus:` rather than `focus-visible:` — items are only ever focused programmatically
|
||||
// while the menu is open, so every focus here is keyboard navigation.
|
||||
@@ -94,8 +95,8 @@ export function BrowserPageContextMenu({
|
||||
closeMenu()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown, true)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, true)
|
||||
addAppKeyListener('keydown', handleKeyDown)
|
||||
return () => removeAppKeyListener('keydown', handleKeyDown)
|
||||
}, [closeMenu, contextMenu])
|
||||
|
||||
// Why: role="menu" is unreachable by keyboard unless focus moves in on open.
|
||||
|
||||
+3
-2
@@ -1,4 +1,5 @@
|
||||
import { useEffect } from 'react'
|
||||
import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router'
|
||||
|
||||
/**
|
||||
* Why: Electron <webview> guests run in a separate process, so clicking the page never dispatches
|
||||
@@ -35,11 +36,11 @@ export function useBrowserAddressBarDismissal(open: boolean, dismissSuggestions:
|
||||
|
||||
window.addEventListener('blur', handleWindowBlur)
|
||||
document.addEventListener('focusin', handleFocusIn, true)
|
||||
window.addEventListener('keydown', handleEscape, true)
|
||||
addAppKeyListener('keydown', handleEscape)
|
||||
return () => {
|
||||
window.removeEventListener('blur', handleWindowBlur)
|
||||
document.removeEventListener('focusin', handleFocusIn, true)
|
||||
window.removeEventListener('keydown', handleEscape, true)
|
||||
removeAppKeyListener('keydown', handleEscape)
|
||||
}
|
||||
}, [dismissSuggestions, open])
|
||||
}
|
||||
|
||||
+3
-2
@@ -11,6 +11,7 @@ import { browserChromeShortcutOwnsEvent } from '../describe-page/browser-overlay
|
||||
import type { BrowserChromeShortcutScope } from '../describe-page/browser-page-types'
|
||||
import type { BrowserAddressBarSelection } from './browser-address-bar-edit-session'
|
||||
import type { BrowserPageGuestFocus } from './browser-page-guest-focus'
|
||||
import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router'
|
||||
|
||||
/** Frames an address-bar grab keeps retrying before it lets the guest have focus back. */
|
||||
const ADDRESS_BAR_FOCUS_FRAMES = 6
|
||||
@@ -182,8 +183,8 @@ export function useBrowserPageChromeFocus({
|
||||
event.stopImmediatePropagation()
|
||||
focusAddressBarNow()
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown, true)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, true)
|
||||
addAppKeyListener('keydown', handleKeyDown)
|
||||
return () => removeAppKeyListener('keydown', handleKeyDown)
|
||||
}, [chromeShortcutScope, focusAddressBarNow, keybindings, workspaceId])
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
+3
-2
@@ -4,6 +4,7 @@ import { useAppStore } from '@/store'
|
||||
import { keybindingMatchesAction } from '../../../../../shared/keybindings'
|
||||
import { browserChromeShortcutOwnsEvent } from '../describe-page/browser-overlay-shortcut-target'
|
||||
import type { BrowserChromeShortcutScope } from '../describe-page/browser-page-types'
|
||||
import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router'
|
||||
|
||||
export function useBrowserPageFindShortcuts({
|
||||
browserTabId,
|
||||
@@ -38,8 +39,8 @@ export function useBrowserPageFindShortcuts({
|
||||
e.stopPropagation()
|
||||
setFindOpen(true)
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown, true)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, true)
|
||||
addAppKeyListener('keydown', handleKeyDown)
|
||||
return () => removeAppKeyListener('keydown', handleKeyDown)
|
||||
}, [chromeShortcutScope, keybindings, setFindOpen, workspaceId])
|
||||
|
||||
// Cmd/Ctrl+F — find in page (IPC path: focus inside webview guest)
|
||||
|
||||
+3
-2
@@ -6,6 +6,7 @@ import { browserChromeShortcutOwnsEvent } from '../describe-page/browser-overlay
|
||||
import type { BrowserChromeShortcutScope, GrabIntent } from '../describe-page/browser-page-types'
|
||||
import { isEditableKeyboardTarget } from './browser-keyboard'
|
||||
import { useBrowserPageWebviewShortcuts } from './use-browser-page-webview-shortcuts'
|
||||
import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router'
|
||||
|
||||
export function useBrowserPageKeyboardShortcuts({
|
||||
browserTabId,
|
||||
@@ -123,8 +124,8 @@ export function useBrowserPageKeyboardShortcuts({
|
||||
e.stopPropagation()
|
||||
handleGrabActionShortcut(key as 'c' | 's')
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown, true)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, true)
|
||||
addAppKeyListener('keydown', handleKeyDown)
|
||||
return () => removeAppKeyListener('keydown', handleKeyDown)
|
||||
}, [grabIsInteractive, handleGrabActionShortcut])
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
+5
-4
@@ -12,6 +12,7 @@ import {
|
||||
type BrowserPageZoomCommand,
|
||||
type BrowserPageZoomDirection
|
||||
} from './browser-page-zoom'
|
||||
import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router'
|
||||
|
||||
/**
|
||||
* History, reload and zoom chords for a <webview>-backed pane — local and client-hosted alike.
|
||||
@@ -72,8 +73,8 @@ export function useBrowserPageWebviewShortcuts({
|
||||
webviewRef.current?.goForward()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown, true)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, true)
|
||||
addAppKeyListener('keydown', handleKeyDown)
|
||||
return () => removeAppKeyListener('keydown', handleKeyDown)
|
||||
}, [chromeShortcutScope, keybindings, webviewRef, workspaceId])
|
||||
|
||||
// Browser history shortcuts (IPC path: focus inside webview guest)
|
||||
@@ -123,8 +124,8 @@ export function useBrowserPageWebviewShortcuts({
|
||||
e.stopImmediatePropagation()
|
||||
reloadWebviewOrRecoverGuest(isHardReload)
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown, true)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, true)
|
||||
addAppKeyListener('keydown', handleKeyDown)
|
||||
return () => removeAppKeyListener('keydown', handleKeyDown)
|
||||
}, [chromeShortcutScope, keybindings, reloadWebviewOrRecoverGuest, workspaceId])
|
||||
|
||||
// Cmd/Ctrl+R — reload (IPC path: focus inside webview guest)
|
||||
|
||||
+3
-2
@@ -16,6 +16,7 @@ import {
|
||||
type RemoteBrowserPaneNotice,
|
||||
type RemoteBrowserRuntimeTarget
|
||||
} from './remote-browser-page-input-model'
|
||||
import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router'
|
||||
|
||||
export function useRemoteBrowserPageContextMenu({
|
||||
busy,
|
||||
@@ -65,8 +66,8 @@ export function useRemoteBrowserPageContextMenu({
|
||||
setContextMenu(null)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown, true)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, true)
|
||||
addAppKeyListener('keydown', handleKeyDown)
|
||||
return () => removeAppKeyListener('keydown', handleKeyDown)
|
||||
}, [contextMenu])
|
||||
|
||||
const handleRemoteContextMenu = (event: React.MouseEvent<HTMLImageElement>): void => {
|
||||
|
||||
+3
-2
@@ -10,6 +10,7 @@ import {
|
||||
REMOTE_BROWSER_FIND_UNAVAILABLE_NOTICE_ID,
|
||||
type RemoteBrowserPaneNotice
|
||||
} from './remote-browser-page-input-model'
|
||||
import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router'
|
||||
|
||||
const FIND_UNAVAILABLE_NOTICE_MS = 4000
|
||||
|
||||
@@ -121,9 +122,9 @@ export function useRemoteBrowserPageChromeChords({
|
||||
// chords land on it rather than one of them silently doing nothing.
|
||||
void runRemoteNavigation('browser.reload')
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown, true)
|
||||
addAppKeyListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown, true)
|
||||
removeAppKeyListener('keydown', handleKeyDown)
|
||||
if (findNoticeTimerRef.current !== null) {
|
||||
clearTimeout(findNoticeTimerRef.current)
|
||||
findNoticeTimerRef.current = null
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
type ContextualTourFloatingPosition
|
||||
} from './contextual-tour-floating-position'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router'
|
||||
|
||||
const FOCUSABLE_SELECTOR =
|
||||
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
@@ -70,7 +71,7 @@ function disposeContextualTourGlobalKeyGuard(): void {
|
||||
if (!guardedWindow.__orcaContextualTourGlobalKeyGuardInstalled) {
|
||||
return
|
||||
}
|
||||
window.removeEventListener('keydown', handleContextualTourGlobalKeyDown, true)
|
||||
removeAppKeyListener('keydown', handleContextualTourGlobalKeyDown)
|
||||
delete guardedWindow.__orcaContextualTourGlobalKeyGuardInstalled
|
||||
}
|
||||
|
||||
@@ -80,7 +81,7 @@ if (typeof window !== 'undefined') {
|
||||
}
|
||||
if (!guardedWindow.__orcaContextualTourGlobalKeyGuardInstalled) {
|
||||
guardedWindow.__orcaContextualTourGlobalKeyGuardInstalled = true
|
||||
window.addEventListener('keydown', handleContextualTourGlobalKeyDown, true)
|
||||
addAppKeyListener('keydown', handleContextualTourGlobalKeyDown)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { keybindingMatchesAction, type KeybindingOverrides } from '../../../../s
|
||||
import type { DictationState } from '../../../../shared/speech-types'
|
||||
import type { GlobalSettings } from '../../../../shared/global-settings-types'
|
||||
import type { DictationInsertionTarget } from './dictation-insertion-target'
|
||||
import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router'
|
||||
|
||||
type HoldDictationGestureOptions = {
|
||||
dictationStateRef: MutableRefObject<DictationState>
|
||||
@@ -176,14 +177,14 @@ export function useHoldDictationGesture({
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown, true)
|
||||
window.addEventListener('keyup', handleKeyUp, true)
|
||||
addAppKeyListener('keydown', handleKeyDown)
|
||||
addAppKeyListener('keyup', handleKeyUp)
|
||||
window.addEventListener('blur', handleBlur)
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
return () => {
|
||||
handleBlur()
|
||||
window.removeEventListener('keydown', handleKeyDown, true)
|
||||
window.removeEventListener('keyup', handleKeyUp, true)
|
||||
removeAppKeyListener('keydown', handleKeyDown)
|
||||
removeAppKeyListener('keyup', handleKeyUp)
|
||||
window.removeEventListener('blur', handleBlur)
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { isMarkdownPreviewFindShortcut } from './markdown-preview-search'
|
||||
import { useMarkdownPreviewDomSearch } from './use-markdown-preview-dom-search'
|
||||
import type { MarkdownPreviewFoundation } from './use-markdown-preview-foundation'
|
||||
import { useMarkdownPreviewScrollViewport } from './use-markdown-preview-scroll-viewport'
|
||||
import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router'
|
||||
|
||||
export function useMarkdownPreviewViewport({
|
||||
foundation,
|
||||
@@ -270,8 +271,8 @@ export function useMarkdownPreviewViewport({
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown, { capture: true })
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true })
|
||||
addAppKeyListener('keydown', handleKeyDown)
|
||||
return () => removeAppKeyListener('keydown', handleKeyDown)
|
||||
}, [
|
||||
activeAnnotationBlockKeyRef,
|
||||
closeSearch,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getShortcutPlatform } from '@/lib/shortcut-platform'
|
||||
import { isEditableTarget } from '@/lib/editable-target'
|
||||
import { isEventTargetInsideFloatingWorkspacePanel } from '@/lib/floating-workspace-terminal-actions'
|
||||
import { keybindingMatchesAction, type KeybindingOverrides } from '../../../../shared/keybindings'
|
||||
import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router'
|
||||
|
||||
/**
|
||||
* Find is this PDF's when the key comes from inside it, or when it owns the panel's commands (the
|
||||
@@ -55,7 +56,7 @@ export function usePdfViewerFindShortcut({
|
||||
e.stopPropagation()
|
||||
openFind()
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown, true)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, true)
|
||||
addAppKeyListener('keydown', handleKeyDown)
|
||||
return () => removeAppKeyListener('keydown', handleKeyDown)
|
||||
}, [keybindings, openFind, ownsCommands, rootRef])
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getShortcutPlatform } from '@/lib/shortcut-platform'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
import { canOpenMarkdownPreview, isMarkdownPreviewShortcut } from './markdown-preview-controls'
|
||||
import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router'
|
||||
|
||||
type UseMarkdownPreviewShortcutParams = {
|
||||
activeFile: OpenFile | null
|
||||
@@ -75,8 +76,8 @@ export function useMarkdownPreviewShortcut({
|
||||
{ sourceFileId: activeFileId ?? undefined }
|
||||
)
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown, { capture: true })
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true })
|
||||
addAppKeyListener('keydown', handleKeyDown)
|
||||
return () => removeAppKeyListener('keydown', handleKeyDown)
|
||||
}, [
|
||||
activeFileDiffSource,
|
||||
activeFileMode,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from './rich-markdown-search'
|
||||
import { createRichMarkdownSearchMatchesCache } from './rich-markdown-search-matches-cache'
|
||||
import { useRichMarkdownSearchHighlights } from './useRichMarkdownSearchHighlights'
|
||||
import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router'
|
||||
|
||||
export function useRichMarkdownSearch({
|
||||
editor,
|
||||
@@ -318,8 +319,8 @@ export function useRichMarkdownSearch({
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown, { capture: true })
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true })
|
||||
addAppKeyListener('keydown', handleKeyDown)
|
||||
return () => removeAppKeyListener('keydown', handleKeyDown)
|
||||
}, [closeSearch, isSearchOpen, keybindings, openReplace, openSearch, rootRef])
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect } from 'react'
|
||||
import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
|
||||
import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router'
|
||||
|
||||
export function useFeatureWallTourKeyboardShortcut({
|
||||
isOpen,
|
||||
@@ -21,7 +22,7 @@ export function useFeatureWallTourKeyboardShortcut({
|
||||
event.preventDefault()
|
||||
onContinue()
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown, { capture: true })
|
||||
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
|
||||
addAppKeyListener('keydown', onKeyDown)
|
||||
return () => removeAppKeyListener('keydown', onKeyDown)
|
||||
}, [enabled, isOpen, onContinue])
|
||||
}
|
||||
|
||||
@@ -4,6 +4,14 @@ import './FloatingTerminalPanel.focus.spec'
|
||||
import './FloatingTerminalPanel.shortcuts.spec'
|
||||
import './FloatingTerminalPanel.tab-lifecycle.spec'
|
||||
|
||||
// Why: the specs read the stubbed window's listeners in place of the app keyboard router's stage.
|
||||
vi.mock('@/lib/app-keyboard-router', () => ({
|
||||
addAppKeyListener: (type: 'keydown' | 'keyup', handler: (e: KeyboardEvent) => void) =>
|
||||
window.addEventListener(type, handler, { capture: true }),
|
||||
removeAppKeyListener: (type: 'keydown' | 'keyup', handler: (e: KeyboardEvent) => void) =>
|
||||
window.removeEventListener(type, handler, { capture: true })
|
||||
}))
|
||||
|
||||
vi.mock('zustand/react/shallow', () => ({
|
||||
// Why: zustand resolves the real react (unmocked in node_modules); the memo wrapper is inert here.
|
||||
useShallow: (selector: unknown) => selector
|
||||
|
||||
+21
-7
@@ -14,6 +14,16 @@ import {
|
||||
import { toModifierDoubleTapEvent } from '../../../../shared/modifier-double-tap-detector'
|
||||
import type { FloatingTerminalPanelLocalState } from './use-floating-terminal-panel-local-state'
|
||||
import type { FloatingTerminalPanelShortcuts } from './use-floating-terminal-panel-shortcuts'
|
||||
import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router'
|
||||
|
||||
const FLOATING_TAB_SWITCH_ACTIONS: readonly KeybindingActionId[] = [
|
||||
'tab.nextSameType',
|
||||
'tab.previousSameType',
|
||||
'tab.nextAllTypes',
|
||||
'tab.previousAllTypes',
|
||||
'tab.nextTerminal',
|
||||
'tab.previousTerminal'
|
||||
]
|
||||
|
||||
type FloatingTerminalGlobalShortcutListenersInput = Pick<
|
||||
FloatingTerminalPanelLocalState,
|
||||
@@ -58,9 +68,6 @@ export function useFloatingTerminalGlobalShortcutListeners({
|
||||
}),
|
||||
Date.now()
|
||||
)
|
||||
if (event.repeat) {
|
||||
return
|
||||
}
|
||||
const state = useAppStore.getState()
|
||||
const context: KeybindingContext = isFloatingWorkspaceTerminalInputTarget(event.target)
|
||||
? 'terminal'
|
||||
@@ -75,6 +82,13 @@ export function useFloatingTerminalGlobalShortcutListeners({
|
||||
event.stopPropagation()
|
||||
event.stopImmediatePropagation()
|
||||
}
|
||||
if (event.repeat) {
|
||||
// Why: a held tab chord switches once; its repeats must not reach the next terminal.
|
||||
if (FLOATING_TAB_SWITCH_ACTIONS.some(matches)) {
|
||||
consume()
|
||||
}
|
||||
return
|
||||
}
|
||||
const dispatchShortcut =
|
||||
floatingShortcutListenersRef.current.handleFloatingPanelShortcutAction
|
||||
if (
|
||||
@@ -137,12 +151,12 @@ export function useFloatingTerminalGlobalShortcutListeners({
|
||||
)
|
||||
}
|
||||
const handleFloatingPanelBlur = (): void => doubleTapDetector?.reset()
|
||||
window.addEventListener('keydown', handleFloatingPanelKeyDown, { capture: true })
|
||||
window.addEventListener('keyup', handleFloatingPanelKeyUp, { capture: true })
|
||||
addAppKeyListener('keydown', handleFloatingPanelKeyDown)
|
||||
addAppKeyListener('keyup', handleFloatingPanelKeyUp)
|
||||
window.addEventListener('blur', handleFloatingPanelBlur)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleFloatingPanelKeyDown, { capture: true })
|
||||
window.removeEventListener('keyup', handleFloatingPanelKeyUp, { capture: true })
|
||||
removeAppKeyListener('keydown', handleFloatingPanelKeyDown)
|
||||
removeAppKeyListener('keyup', handleFloatingPanelKeyUp)
|
||||
window.removeEventListener('blur', handleFloatingPanelBlur)
|
||||
doubleTapDetector?.reset()
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { translate } from '@/i18n/i18n'
|
||||
import ProjectPicker from './ProjectPicker'
|
||||
import type { ProjectViewTableState } from './useProjectViewTable'
|
||||
import { githubProjectHost } from '../../../../shared/github/project-identity'
|
||||
import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router'
|
||||
|
||||
export function ProjectViewToolbar({ tableState }: { tableState: ProjectViewTableState }) {
|
||||
const {
|
||||
@@ -173,8 +174,8 @@ function ProjectSearchInput({
|
||||
input.focus()
|
||||
input.select()
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown, { capture: true })
|
||||
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
|
||||
addAppKeyListener('keydown', onKeyDown)
|
||||
return () => removeAppKeyListener('keydown', onKeyDown)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useAppStore } from '../../store'
|
||||
import type { NativeChatComposerHandle } from './native-chat-composer-types'
|
||||
import type { NativeChatMessageListHandle } from './use-native-chat-reveal-latest'
|
||||
import { focusNativeChatPromptCard } from './use-native-chat-prompt-card-focus'
|
||||
import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router'
|
||||
|
||||
/** The chat's find: open state and query live with the chat, so a reopen keeps the last query. */
|
||||
export type NativeChatFind = {
|
||||
@@ -122,8 +123,8 @@ export function useNativeChatFind(
|
||||
open()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown, { capture: true })
|
||||
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
|
||||
addAppKeyListener('keydown', onKeyDown)
|
||||
return () => removeAppKeyListener('keydown', onKeyDown)
|
||||
}, [enabled, open, rootRef])
|
||||
|
||||
// Why React capture on the root: it runs before the composer's own onKeyDownCapture (outer
|
||||
|
||||
@@ -4,6 +4,7 @@ import { writeNativeChatFontSize } from './native-chat-font-size-write'
|
||||
import { isWebClientLocation } from '@/lib/web-client-location'
|
||||
import { getShortcutPlatform } from '@/lib/shortcut-platform'
|
||||
import { useAppStore } from '../../store'
|
||||
import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router'
|
||||
|
||||
export function useNativeChatFontSize(
|
||||
enabled: boolean,
|
||||
@@ -32,7 +33,7 @@ export function useNativeChatFontSize(
|
||||
e.stopPropagation()
|
||||
void writeNativeChatFontSize(action)
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown, { capture: true })
|
||||
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
|
||||
addAppKeyListener('keydown', onKeyDown)
|
||||
return () => removeAppKeyListener('keydown', onKeyDown)
|
||||
}, [enabled, rootRef])
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
isNativeChatTabWideFallbackSafe,
|
||||
resolveNativeChatActiveLayoutLeafId
|
||||
} from './native-chat-leaf-routing'
|
||||
import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router'
|
||||
|
||||
export function resolveNativeChatToggleShortcutDetectedAgent({
|
||||
terminalTabId,
|
||||
@@ -104,9 +105,9 @@ export function useNativeChatToggleShortcut(worktreeId: string, isWorktreeActive
|
||||
e.stopPropagation()
|
||||
state.toggleTabViewMode(tab.id)
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown, { capture: true })
|
||||
addAppKeyListener('keydown', onKeyDown)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown, { capture: true })
|
||||
removeAppKeyListener('keydown', onKeyDown)
|
||||
}
|
||||
}, [worktreeId, isWorktreeActive])
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { OnboardingFooter } from './OnboardingFooter'
|
||||
import { shouldRequestOnboardingSkipConfirmation } from './onboarding-dismiss-target'
|
||||
import logo from '../../../../../resources/logo.svg'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router'
|
||||
|
||||
const stepCopy = {
|
||||
agent: {
|
||||
@@ -177,8 +178,8 @@ export default function OnboardingFlow({
|
||||
event.preventDefault()
|
||||
void flowNext('keyboard')
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown, { capture: true })
|
||||
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
|
||||
addAppKeyListener('keydown', onKeyDown)
|
||||
return () => removeAppKeyListener('keydown', onKeyDown)
|
||||
}, [flowNext])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -189,8 +190,8 @@ export default function OnboardingFlow({
|
||||
event.preventDefault()
|
||||
requestSkipConfirmation('keyboard')
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown, { capture: true })
|
||||
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
|
||||
addAppKeyListener('keydown', onKeyDown)
|
||||
return () => removeAppKeyListener('keydown', onKeyDown)
|
||||
}, [requestSkipConfirmation, skipConfirmOpen])
|
||||
|
||||
return (
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { keybindingMatchesAction } from '../../../../shared/keybindings'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { isEditableTarget } from '@/lib/editable-target'
|
||||
import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router'
|
||||
|
||||
export function shouldIgnoreFileExplorerKeyTarget(target: EventTarget | null): boolean {
|
||||
return (
|
||||
@@ -288,7 +289,7 @@ export function useFileExplorerKeys(opts: {
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown, { capture: true })
|
||||
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
|
||||
addAppKeyListener('keydown', onKeyDown)
|
||||
return () => removeAppKeyListener('keydown', onKeyDown)
|
||||
}, [keybindings, rightSidebarExplorerView, rightSidebarOpen, rightSidebarTab, opts.containerRef])
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router'
|
||||
|
||||
let deleteModifierPressed = false
|
||||
let listenersInstalled = false
|
||||
@@ -41,8 +42,8 @@ function installListeners(): void {
|
||||
listenersInstalled = true
|
||||
// Why: Option/Alt is a transient reveal modifier, so stale key state after
|
||||
// focus loss would leave destructive affordances visible.
|
||||
window.addEventListener('keydown', onKeyDown, { capture: true })
|
||||
window.addEventListener('keyup', onKeyUp, { capture: true })
|
||||
addAppKeyListener('keydown', onKeyDown)
|
||||
addAppKeyListener('keyup', onKeyUp)
|
||||
window.addEventListener('blur', clearDeleteModifierPressed)
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('visibilitychange', clearDeleteModifierPressed)
|
||||
@@ -54,8 +55,8 @@ function uninstallListeners(): void {
|
||||
return
|
||||
}
|
||||
listenersInstalled = false
|
||||
window.removeEventListener('keydown', onKeyDown, { capture: true })
|
||||
window.removeEventListener('keyup', onKeyUp, { capture: true })
|
||||
removeAppKeyListener('keydown', onKeyDown)
|
||||
removeAppKeyListener('keyup', onKeyUp)
|
||||
window.removeEventListener('blur', clearDeleteModifierPressed)
|
||||
if (typeof document !== 'undefined') {
|
||||
document.removeEventListener('visibilitychange', clearDeleteModifierPressed)
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
} from './drop-commit-context'
|
||||
import { commitWorktreePointerDrop } from './pointer-commit'
|
||||
import type { WorktreePointerDrag } from './row-state'
|
||||
import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router'
|
||||
|
||||
const SIDEBAR_POINTER_DRAG_THRESHOLD_PX = 4
|
||||
|
||||
@@ -87,12 +88,12 @@ export function useWorktreePointerDragWindowEvents(args: {
|
||||
clearWorktreeDrag()
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown, { capture: true })
|
||||
addAppKeyListener('keydown', handleKeyDown)
|
||||
window.addEventListener('pointermove', handlePointerMove, { capture: true })
|
||||
window.addEventListener('pointerup', handlePointerUp, { capture: true })
|
||||
window.addEventListener('pointercancel', handlePointerCancel, { capture: true })
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown, { capture: true })
|
||||
removeAppKeyListener('keydown', handleKeyDown)
|
||||
window.removeEventListener('pointermove', handlePointerMove, { capture: true })
|
||||
window.removeEventListener('pointerup', handlePointerUp, { capture: true })
|
||||
window.removeEventListener('pointercancel', handlePointerCancel, { capture: true })
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
resolveCycledWorktreeId
|
||||
} from '../../worktree-keyboard-cycle'
|
||||
import { findPreferredRenderRowIndexForWorktreeIdentity } from './render-row-lookup'
|
||||
import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router'
|
||||
|
||||
export function useWorktreeListKeyboardNavigation(args: {
|
||||
rows: HostSectionRow[]
|
||||
@@ -126,11 +127,11 @@ export function useWorktreeListKeyboardNavigation(args: {
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown, { capture: true })
|
||||
addAppKeyListener('keydown', handleKeyDown)
|
||||
window.addEventListener('pointerdown', endListNavigation, { capture: true })
|
||||
window.addEventListener('focusout', endListNavigation, { capture: true })
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown, { capture: true })
|
||||
removeAppKeyListener('keydown', handleKeyDown)
|
||||
window.removeEventListener('pointerdown', endListNavigation, { capture: true })
|
||||
window.removeEventListener('focusout', endListNavigation, { capture: true })
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect } from 'react'
|
||||
import type { SkillsPageView } from './skills-page-view'
|
||||
import { hasVisibleOverlay } from '@/lib/visible-overlay'
|
||||
import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router'
|
||||
|
||||
type UseSkillsPageKeyboardNavigationOptions = {
|
||||
closeSkillsPage: () => void
|
||||
@@ -47,7 +48,7 @@ export function useSkillsPageKeyboardNavigation({
|
||||
}
|
||||
|
||||
// Why: tooltips can consume Escape before bubble listeners see it.
|
||||
window.addEventListener('keydown', handleKeyDown, { capture: true })
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true })
|
||||
addAppKeyListener('keydown', handleKeyDown)
|
||||
return () => removeAppKeyListener('keydown', handleKeyDown)
|
||||
}, [closeSkillsPage, exitSelection, exitSharedLinks, selectionMode, view])
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef } from 'react'
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
|
||||
import { agentTurnEndedUncleanly } from '../../../../shared/agent-main-agent-verdict'
|
||||
import { useAppStore } from '@/store'
|
||||
import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router'
|
||||
|
||||
// Why: leave a short quiet window after agents finish so the prompt does not
|
||||
// interrupt follow-up typing or status churn from the completed run.
|
||||
@@ -117,10 +118,10 @@ export function StarNagAgentValueMomentObserver(): null {
|
||||
}
|
||||
lastTypingAtRef.current = Date.now()
|
||||
}
|
||||
window.addEventListener('keydown', markTyping, true)
|
||||
addAppKeyListener('keydown', markTyping)
|
||||
window.addEventListener('input', markTyping, true)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', markTyping, true)
|
||||
removeAppKeyListener('keydown', markTyping)
|
||||
window.removeEventListener('input', markTyping, true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type RecentTabSwitcherItem
|
||||
} from './recent-tab-switching'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router'
|
||||
|
||||
type SwitcherState = {
|
||||
items: RecentTabSwitcherItem[]
|
||||
@@ -136,12 +137,12 @@ export default function RecentTabSwitcher(): React.JSX.Element | null {
|
||||
consumeKeyboardEvent(event)
|
||||
commit()
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown, { capture: true })
|
||||
window.addEventListener('keyup', onKeyUp, { capture: true })
|
||||
addAppKeyListener('keydown', onKeyDown)
|
||||
addAppKeyListener('keyup', onKeyUp)
|
||||
window.addEventListener('blur', cancel)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown, { capture: true })
|
||||
window.removeEventListener('keyup', onKeyUp, { capture: true })
|
||||
removeAppKeyListener('keydown', onKeyDown)
|
||||
removeAppKeyListener('keyup', onKeyUp)
|
||||
window.removeEventListener('blur', cancel)
|
||||
}
|
||||
}, [cancel, commit, openOrAdvance])
|
||||
|
||||
@@ -58,6 +58,14 @@ vi.mock('../../../../shared/keybindings', () => ({
|
||||
keybindingMatchesAction: keybindingsMock.matchAction
|
||||
}))
|
||||
|
||||
// Why: the stubbed window stands in for the app keyboard router's capture stage.
|
||||
vi.mock('@/lib/app-keyboard-router', () => ({
|
||||
addAppKeyListener: (type: 'keydown' | 'keyup', handler: (e: KeyboardEvent) => void) =>
|
||||
window.addEventListener(type, handler, { capture: true }),
|
||||
removeAppKeyListener: (type: 'keydown' | 'keyup', handler: (e: KeyboardEvent) => void) =>
|
||||
window.removeEventListener(type, handler, { capture: true })
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/shortcut-platform', () => ({
|
||||
getShortcutPlatform: () => 'darwin' as const
|
||||
}))
|
||||
@@ -165,7 +173,7 @@ beforeEach(() => {
|
||||
appStoreMock.state.keybindings = {}
|
||||
appStoreMock.state.settings.terminalShortcutPolicy = 'orca-first'
|
||||
vi.stubGlobal('window', {
|
||||
addEventListener: vi.fn((type: string, handler: (e: KeyboardEvent) => void) => {
|
||||
addEventListener: vi.fn((type: 'keydown' | 'keyup', handler: (e: KeyboardEvent) => void) => {
|
||||
windowListeners.set(type, handler)
|
||||
}),
|
||||
removeEventListener: vi.fn()
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { getShortcutPlatform } from '@/lib/shortcut-platform'
|
||||
import { TOGGLE_QUICK_COMMANDS_MENU_EVENT } from '@/lib/quick-commands-menu-events'
|
||||
import { useAppStore } from '@/store'
|
||||
import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router'
|
||||
|
||||
type UseTabBarQuickCommandsShortcutParams = {
|
||||
isFocused: boolean
|
||||
@@ -126,12 +127,12 @@ export function useTabBarQuickCommandsShortcut({
|
||||
)
|
||||
}
|
||||
const onBlur = (): void => doubleTapDetector.reset()
|
||||
window.addEventListener('keydown', onKeyDown, { capture: true })
|
||||
window.addEventListener('keyup', onKeyUp, { capture: true })
|
||||
addAppKeyListener('keydown', onKeyDown)
|
||||
addAppKeyListener('keyup', onKeyUp)
|
||||
window.addEventListener('blur', onBlur)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown, { capture: true })
|
||||
window.removeEventListener('keyup', onKeyUp, { capture: true })
|
||||
removeAppKeyListener('keydown', onKeyDown)
|
||||
removeAppKeyListener('keyup', onKeyUp)
|
||||
window.removeEventListener('blur', onBlur)
|
||||
}
|
||||
}, [activeView, isFocused, keybindings, menuOpen, onOpenChange, terminalShortcutPolicy])
|
||||
|
||||
@@ -163,7 +163,7 @@ export function createTerminalKeyboardEventHandlers(context: EventContext) {
|
||||
// Cmd+G / Cmd+Shift+G navigates terminal search matches even when focus
|
||||
// is inside the search input itself, so this check must run before the
|
||||
// editable-target guard would otherwise bypass all terminal shortcuts.
|
||||
// stopImmediatePropagation prevents App.tsx's Cmd+Shift+G (source-control sidebar) from also firing.
|
||||
// App owners run before this listener; source control's Cmd+Shift+G stands down while terminal search is open.
|
||||
const direction = matchSearchNavigate(e, isMac, searchOpenRef.current, searchStateRef.current)
|
||||
if (direction !== null) {
|
||||
if (e.repeat) {
|
||||
|
||||
@@ -306,19 +306,25 @@ describe('shared tab navigation routing', () => {
|
||||
mocks.state = { activeWorktreeId: controller.activeWorktreeId }
|
||||
})
|
||||
|
||||
function nextTab(): void {
|
||||
handleTerminalWorkspaceKeyDown(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: ']',
|
||||
metaKey: true,
|
||||
shiftKey: true,
|
||||
cancelable: true
|
||||
}),
|
||||
controller,
|
||||
'darwin'
|
||||
)
|
||||
function nextTab(repeat = false): KeyboardEvent {
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: ']',
|
||||
metaKey: true,
|
||||
shiftKey: true,
|
||||
cancelable: true,
|
||||
repeat
|
||||
})
|
||||
handleTerminalWorkspaceKeyDown(event, controller, 'darwin')
|
||||
return event
|
||||
}
|
||||
|
||||
it('claims a held tab chord on every repeat but switches once', () => {
|
||||
nextTab()
|
||||
const repeat = nextTab(true)
|
||||
expect(repeat.defaultPrevented).toBe(true)
|
||||
expect(handleSwitchTabAcrossAllTypes).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('sends the keyboard and IPC navigation intent to the same workspace operation', () => {
|
||||
nextTab()
|
||||
dispatchWorkspaceTabCommand({ type: 'switch', direction: 1, scope: 'all-types' })
|
||||
|
||||
@@ -227,11 +227,18 @@ export function handleTerminalWorkspaceKeyDown(
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (!event.repeat && matchShortcut('tab.previousRecent')) {
|
||||
// Why: a held tab chord is claimed on every repeat but switches once; an unclaimed repeat
|
||||
// would reach the newly active terminal as its own key (Cmd+Right as Ctrl+E).
|
||||
const claimTabSwitch = (): boolean => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.stopImmediatePropagation()
|
||||
dispatchWorkspaceTabCommand({ type: 'previous-recent' })
|
||||
return !event.repeat
|
||||
}
|
||||
if (matchShortcut('tab.previousRecent')) {
|
||||
if (claimTabSwitch()) {
|
||||
dispatchWorkspaceTabCommand({ type: 'previous-recent' })
|
||||
}
|
||||
return
|
||||
}
|
||||
const switchSameTypeDirection = matchShortcut('tab.nextSameType')
|
||||
@@ -244,10 +251,10 @@ export function handleTerminalWorkspaceKeyDown(
|
||||
: matchShortcut('tab.previousAllTypes')
|
||||
? -1
|
||||
: null
|
||||
if (!event.repeat && (switchSameTypeDirection !== null || switchAllTypesDirection !== null)) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.stopImmediatePropagation()
|
||||
if (switchSameTypeDirection !== null || switchAllTypesDirection !== null) {
|
||||
if (!claimTabSwitch()) {
|
||||
return
|
||||
}
|
||||
notifyTerminalCapture(
|
||||
switchAllTypesDirection !== null
|
||||
? switchAllTypesDirection === 1
|
||||
@@ -268,10 +275,7 @@ export function handleTerminalWorkspaceKeyDown(
|
||||
: matchShortcut('tab.previousTerminal')
|
||||
? -1
|
||||
: null
|
||||
if (!event.repeat && terminalTabDirection !== null) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.stopImmediatePropagation()
|
||||
if (terminalTabDirection !== null && claimTabSwitch()) {
|
||||
dispatchWorkspaceTabCommand({
|
||||
type: 'switch',
|
||||
direction: terminalTabDirection,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { TaskPageJiraSummaryGenerationModel } from './use-task-page-jira-summary-generation'
|
||||
import { useEffect } from 'react'
|
||||
import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router'
|
||||
export function useTaskPageGlobalEffects(model: TaskPageJiraSummaryGenerationModel) {
|
||||
const {
|
||||
closeTaskPage,
|
||||
@@ -72,13 +73,8 @@ export function useTaskPageGlobalEffects(model: TaskPageJiraSummaryGenerationMod
|
||||
event.preventDefault()
|
||||
closeTaskPage()
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown, {
|
||||
capture: true
|
||||
})
|
||||
return () =>
|
||||
window.removeEventListener('keydown', onKeyDown, {
|
||||
capture: true
|
||||
})
|
||||
addAppKeyListener('keydown', onKeyDown)
|
||||
return () => removeAppKeyListener('keydown', onKeyDown)
|
||||
}, [
|
||||
activeModal,
|
||||
closeTaskPage,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { translate } from '@/i18n/i18n'
|
||||
import type { GitHubTaskKind } from '@/components/task-page-localized-options'
|
||||
import { getTaskPresetQuery } from '../../../shared/task-preset-query'
|
||||
import { shouldSuppressEnterSubmit } from '@/lib/new-workspace-enter-guard'
|
||||
import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router'
|
||||
export function useTaskPageSearchActions(model: TaskPageGitHubQuietRefreshModel) {
|
||||
const {
|
||||
setTaskResumeState,
|
||||
@@ -218,13 +219,8 @@ export function useTaskPageSearchActions(model: TaskPageGitHubQuietRefreshModel)
|
||||
input.focus()
|
||||
input.select()
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown, {
|
||||
capture: true
|
||||
})
|
||||
return () =>
|
||||
window.removeEventListener('keydown', onKeyDown, {
|
||||
capture: true
|
||||
})
|
||||
addAppKeyListener('keydown', onKeyDown)
|
||||
return () => removeAppKeyListener('keydown', onKeyDown)
|
||||
}, [
|
||||
activeModal,
|
||||
dialogWorkItem,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect } from 'react'
|
||||
import { handleTerminalWorkspaceKeyDown } from './terminal-workspace-keydown'
|
||||
import type { TerminalActivationController } from './use-terminal-activation-actions'
|
||||
import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router'
|
||||
|
||||
export function useTerminalKeyboardShortcuts(controller: TerminalActivationController): void {
|
||||
const {
|
||||
@@ -28,8 +29,8 @@ export function useTerminalKeyboardShortcuts(controller: TerminalActivationContr
|
||||
const onKeyDown = (event: KeyboardEvent): void => {
|
||||
handleTerminalWorkspaceKeyDown(event, controller, shortcutPlatform)
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown, { capture: true })
|
||||
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
|
||||
addAppKeyListener('keydown', onKeyDown)
|
||||
return () => removeAppKeyListener('keydown', onKeyDown)
|
||||
// oxlint-disable-next-line react-hooks/exhaustive-deps -- preserve the original listener refresh contract across extraction.
|
||||
}, [
|
||||
activeWorktreeId,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { DESKTOP_AWAY_SECONDS } from '../../../shared/desktop-away-threshold'
|
||||
import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router'
|
||||
|
||||
// The one record of user presence, from trusted renderer input only: the on-screen scan's gate
|
||||
// and the marking guard both read it. Main's "not away" only answers the scan that asked — it
|
||||
@@ -74,13 +75,13 @@ export function subscribeAutoAckPresenceSignals(
|
||||
window.addEventListener('pointermove', input)
|
||||
window.addEventListener('wheel', input, { passive: true })
|
||||
// Why capture: xterm stops the keydowns it handles before they bubble to the window.
|
||||
window.addEventListener('keydown', input, { capture: true })
|
||||
addAppKeyListener('keydown', input)
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', onRescan)
|
||||
window.removeEventListener('focus', onRescan)
|
||||
window.removeEventListener('pointerdown', input)
|
||||
window.removeEventListener('pointermove', input)
|
||||
window.removeEventListener('wheel', input)
|
||||
window.removeEventListener('keydown', input, { capture: true })
|
||||
removeAppKeyListener('keydown', input)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
addAppKeyListener,
|
||||
installAppKeyboardRouter,
|
||||
removeAppKeyListener
|
||||
} from './app-keyboard-router'
|
||||
|
||||
installAppKeyboardRouter(window)
|
||||
|
||||
const cleanups: (() => void)[] = []
|
||||
afterEach(() => {
|
||||
cleanups.splice(0).forEach((cleanup) => cleanup())
|
||||
})
|
||||
|
||||
function onWindow(listener: (event: KeyboardEvent) => void): void {
|
||||
window.addEventListener('keydown', listener, { capture: true })
|
||||
cleanups.push(() => window.removeEventListener('keydown', listener, { capture: true }))
|
||||
}
|
||||
|
||||
function onApp(listener: (event: KeyboardEvent) => void): void {
|
||||
addAppKeyListener('keydown', listener)
|
||||
cleanups.push(() => removeAppKeyListener('keydown', listener))
|
||||
}
|
||||
|
||||
function press(): string[] {
|
||||
const seen: string[] = []
|
||||
const record = (name: string) => () => seen.push(name)
|
||||
const target = document.createElement('textarea')
|
||||
document.body.append(target)
|
||||
document.addEventListener('keydown', record('document'), { capture: true, once: true })
|
||||
target.addEventListener('keydown', record('terminal input'), { once: true })
|
||||
cleanups.push(() => target.remove())
|
||||
target.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowRight', metaKey: true, bubbles: true })
|
||||
)
|
||||
return seen
|
||||
}
|
||||
|
||||
describe('app keyboard router', () => {
|
||||
// STA-11205: a tab shortcut owner re-registered after the terminal pane still runs first.
|
||||
it.each(['pane first', 'app owner first'])(
|
||||
'runs app owners ahead of the terminal pane whatever the registration order (%s)',
|
||||
(order) => {
|
||||
const seen: string[] = []
|
||||
const pane = (event: KeyboardEvent): void => {
|
||||
seen.push('pane translation')
|
||||
event.stopImmediatePropagation()
|
||||
}
|
||||
const tabSwitch = (event: KeyboardEvent): void => {
|
||||
seen.push('tab switch')
|
||||
event.stopImmediatePropagation()
|
||||
}
|
||||
if (order === 'pane first') {
|
||||
onWindow(pane)
|
||||
onApp(tabSwitch)
|
||||
} else {
|
||||
onApp(tabSwitch)
|
||||
onWindow(pane)
|
||||
}
|
||||
const tail = press()
|
||||
expect([...seen, ...tail]).toEqual(['tab switch'])
|
||||
}
|
||||
)
|
||||
|
||||
it('leaves the terminal fallback when no app owner claims the key', () => {
|
||||
const seen: string[] = []
|
||||
onWindow(() => seen.push('pane translation'))
|
||||
onApp(() => seen.push('owner declined'))
|
||||
const tail = press()
|
||||
expect([...seen, ...tail]).toEqual([
|
||||
'owner declined',
|
||||
'pane translation',
|
||||
'document',
|
||||
'terminal input'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps native stop semantics between app owners', () => {
|
||||
const seen: string[] = []
|
||||
onApp((event) => {
|
||||
seen.push('stops propagation')
|
||||
event.stopPropagation()
|
||||
})
|
||||
onApp(() => seen.push('later owner'))
|
||||
onWindow(() => seen.push('pane'))
|
||||
const tail = press()
|
||||
expect([...seen, ...tail]).toEqual(['stops propagation', 'later owner', 'pane'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
// Why: app shortcut owners and terminal panes both listen on window capture, so React's
|
||||
// effect order decided who saw a chord first (STA-11205). The router is installed before
|
||||
// React renders and runs every app owner, in registration order, ahead of any pane listener.
|
||||
|
||||
type AppKeyEventType = 'keydown' | 'keyup'
|
||||
type AppKeyListener = (event: KeyboardEvent) => void
|
||||
type Registration = { listener: AppKeyListener; active: boolean }
|
||||
|
||||
const registrations: Record<AppKeyEventType, Registration[]> = { keydown: [], keyup: [] }
|
||||
let installedOn: Window | null = null
|
||||
|
||||
function dispatch(type: AppKeyEventType, event: KeyboardEvent): void {
|
||||
// Why: like native listeners, owners added during a dispatch first see the next event.
|
||||
const snapshot = registrations[type].slice()
|
||||
let stoppedImmediately = false
|
||||
const originalStop = event.stopImmediatePropagation
|
||||
// Why: only an immediate stop ends the app stage; plain stopPropagation still lets the
|
||||
// remaining window owners run, as it did when each owner was its own window listener.
|
||||
event.stopImmediatePropagation = () => {
|
||||
stoppedImmediately = true
|
||||
originalStop.call(event)
|
||||
}
|
||||
try {
|
||||
for (const registration of snapshot) {
|
||||
if (stoppedImmediately) {
|
||||
break
|
||||
}
|
||||
if (!registration.active) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
registration.listener(event)
|
||||
} catch (error) {
|
||||
// Why: a throwing owner must not cost the rest their shortcuts, as with native listeners.
|
||||
reportError(error)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
event.stopImmediatePropagation = originalStop
|
||||
}
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent): void => dispatch('keydown', event)
|
||||
const onKeyUp = (event: KeyboardEvent): void => dispatch('keyup', event)
|
||||
|
||||
/** Call before React renders so the router's window listeners precede every pane's. */
|
||||
export function installAppKeyboardRouter(target: Window = window): void {
|
||||
if (installedOn === target) {
|
||||
return
|
||||
}
|
||||
installedOn?.removeEventListener('keydown', onKeyDown, { capture: true })
|
||||
installedOn?.removeEventListener('keyup', onKeyUp, { capture: true })
|
||||
target.addEventListener('keydown', onKeyDown, { capture: true })
|
||||
target.addEventListener('keyup', onKeyUp, { capture: true })
|
||||
installedOn = target
|
||||
}
|
||||
|
||||
/** Window-capture key listener for app shortcut owners; terminal panes keep their own. */
|
||||
export function addAppKeyListener(type: AppKeyEventType, listener: AppKeyListener): void {
|
||||
installAppKeyboardRouter(installedOn ?? window)
|
||||
const list = registrations[type]
|
||||
if (list.some((registration) => registration.active && registration.listener === listener)) {
|
||||
return
|
||||
}
|
||||
list.push({ listener, active: true })
|
||||
}
|
||||
|
||||
export function removeAppKeyListener(type: AppKeyEventType, listener: AppKeyListener): void {
|
||||
const list = registrations[type]
|
||||
const index = list.findIndex(
|
||||
(registration) => registration.active && registration.listener === listener
|
||||
)
|
||||
if (index === -1) {
|
||||
return
|
||||
}
|
||||
list[index].active = false
|
||||
list.splice(index, 1)
|
||||
}
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.dispose(() => {
|
||||
installedOn?.removeEventListener('keydown', onKeyDown, { capture: true })
|
||||
installedOn?.removeEventListener('keyup', onKeyUp, { capture: true })
|
||||
installedOn = null
|
||||
})
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { installAutomationHostDiagnostic } from './components/automations/automation-host-diagnostics'
|
||||
import { applyDocumentTheme } from './lib/document-theme'
|
||||
import { installTypingLatencyDiagnostic } from './lib/typing-latency/diagnostic'
|
||||
import { installAppKeyboardRouter } from './lib/app-keyboard-router'
|
||||
import { shouldEnableReactGrab } from './lib/react-grab-dev-gate'
|
||||
import { I18nProvider } from './i18n/I18nProvider'
|
||||
import { translate } from './i18n/i18n'
|
||||
@@ -31,6 +32,7 @@ import.meta.hot?.dispose(disposeOsFileDropGuard)
|
||||
recordRendererCrashBreadcrumb('renderer_bootstrap_started', { dev: import.meta.env.DEV })
|
||||
installRendererCrashDiagnostics()
|
||||
installTypingLatencyDiagnostic()
|
||||
installAppKeyboardRouter()
|
||||
installAutomationHostDiagnostic()
|
||||
|
||||
if (
|
||||
|
||||
@@ -22,9 +22,11 @@ import { WebPairingSecurityNotice } from './WebPairingSecurityNotice'
|
||||
import { I18nProvider } from '../i18n/I18nProvider'
|
||||
import { translate } from '../i18n/i18n'
|
||||
import { installOsFileDropCancellationGuard } from '../lib/os-file-drop-cancellation-guard'
|
||||
import { installAppKeyboardRouter } from '../lib/app-keyboard-router'
|
||||
|
||||
const disposeOsFileDropGuard = installOsFileDropCancellationGuard()
|
||||
import.meta.hot?.dispose(disposeOsFileDropGuard)
|
||||
installAppKeyboardRouter()
|
||||
const App = lazy(() => import('../App'))
|
||||
|
||||
function WebRoot(): React.JSX.Element {
|
||||
|
||||
Reference in New Issue
Block a user