fix(quick-open): restore panel focus when dismissing the Go to file dialog
Restore focus to the active panel when dismissing QuickOpen, including terminal, editor, simulator, local browser, and remote browser surfaces. Reviewed-by: Codex
This commit is contained in:
parent
21d0391881
commit
bb0e98fcd1
|
|
@ -15,6 +15,7 @@ import {
|
|||
} from '@/components/ui/command'
|
||||
import { prepareQuickOpenFiles, rankQuickOpenFiles } from '@/components/quick-open-search'
|
||||
import { useRuntimeFileListForWorktree } from '@/components/quick-open-file-list'
|
||||
import { useModalReturnFocus } from '@/hooks/useModalReturnFocus'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
/**
|
||||
|
|
@ -176,6 +177,11 @@ export default function QuickOpen(): React.JSX.Element | null {
|
|||
|
||||
const worktreePath = activeWorktree?.path ?? null
|
||||
|
||||
// Why: Radix's onCloseAutoFocus restore is suppressed below, so dismissing
|
||||
// the dialog (Esc / click-away) would otherwise leave the active panel
|
||||
// unfocused. This returns focus to the surface that was active on open.
|
||||
const { captureReturnFocus, skipReturnFocus } = useModalReturnFocus(visible)
|
||||
|
||||
// Why: reset input only on open. Keeping this out of the file-load effect
|
||||
// prevents unrelated store updates (which can produce a new excludePaths
|
||||
// array reference) from wiping a query the user is currently typing.
|
||||
|
|
@ -198,6 +204,9 @@ export default function QuickOpen(): React.JSX.Element | null {
|
|||
if (!activeWorktreeId || !worktreePath) {
|
||||
return
|
||||
}
|
||||
// Why: opening a file moves focus into the editor; don't restore focus to
|
||||
// the surface that was active before QuickOpen opened.
|
||||
skipReturnFocus()
|
||||
closeModal()
|
||||
openFile({
|
||||
filePath: joinPath(worktreePath, relativePath),
|
||||
|
|
@ -207,7 +216,7 @@ export default function QuickOpen(): React.JSX.Element | null {
|
|||
mode: 'edit'
|
||||
})
|
||||
},
|
||||
[activeWorktreeId, worktreePath, openFile, closeModal]
|
||||
[activeWorktreeId, worktreePath, openFile, closeModal, skipReturnFocus]
|
||||
)
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
|
|
@ -224,11 +233,16 @@ export default function QuickOpen(): React.JSX.Element | null {
|
|||
e.preventDefault()
|
||||
}, [])
|
||||
|
||||
const handleOpenAutoFocus = useCallback(() => {
|
||||
captureReturnFocus()
|
||||
}, [captureReturnFocus])
|
||||
|
||||
return (
|
||||
<CommandDialog
|
||||
open={visible}
|
||||
onOpenChange={handleOpenChange}
|
||||
shouldFilter={false}
|
||||
onOpenAutoFocus={handleOpenAutoFocus}
|
||||
onCloseAutoFocus={handleCloseAutoFocus}
|
||||
title={translate('auto.components.QuickOpen.ec31e058f7', 'Go to file')}
|
||||
description={translate('auto.components.QuickOpen.9e97f08d0f', 'Search for a file to open')}
|
||||
|
|
|
|||
|
|
@ -1863,6 +1863,32 @@ function RemoteBrowserPagePane({
|
|||
})
|
||||
}, [isActive])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
return
|
||||
}
|
||||
const handleBrowserFocusRequest = (event: Event): void => {
|
||||
const detail = (event as CustomEvent<BrowserFocusRequestDetail>).detail
|
||||
if (!detail || detail.pageId !== browserTab.id) {
|
||||
return
|
||||
}
|
||||
const focusTarget = consumeBrowserFocusRequest(browserTab.id)
|
||||
if (!focusTarget) {
|
||||
return
|
||||
}
|
||||
if (focusTarget === 'address-bar') {
|
||||
addressBarInputRef.current?.focus()
|
||||
addressBarInputRef.current?.select()
|
||||
return
|
||||
}
|
||||
const target = imageRef.current ?? remoteViewportRef.current
|
||||
target?.focus()
|
||||
}
|
||||
window.addEventListener(ORCA_BROWSER_FOCUS_REQUEST_EVENT, handleBrowserFocusRequest)
|
||||
return () =>
|
||||
window.removeEventListener(ORCA_BROWSER_FOCUS_REQUEST_EVENT, handleBrowserFocusRequest)
|
||||
}, [browserTab.id, isActive])
|
||||
|
||||
const runRemoteNavigation = useCallback(
|
||||
async (
|
||||
method: 'browser.goto' | 'browser.back' | 'browser.forward' | 'browser.reload',
|
||||
|
|
@ -2515,6 +2541,7 @@ function RemoteBrowserPagePane({
|
|||
</div>
|
||||
<div
|
||||
ref={remoteViewportRef}
|
||||
tabIndex={-1}
|
||||
className="relative min-h-0 flex-1 overflow-hidden bg-background"
|
||||
>
|
||||
{frameUrl ? (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,87 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { resolveModalReturnFocusAction } from './modal-return-focus-action'
|
||||
|
||||
describe('resolveModalReturnFocusAction', () => {
|
||||
it('returns none when nothing was captured', () => {
|
||||
expect(resolveModalReturnFocusAction(null)).toEqual({ kind: 'none' })
|
||||
})
|
||||
|
||||
it('routes browser surfaces through the browser focus channel', () => {
|
||||
expect(
|
||||
resolveModalReturnFocusAction({
|
||||
tabType: 'browser',
|
||||
worktreeId: 'wt-1',
|
||||
browserPageId: 'page-1',
|
||||
browserTarget: 'address-bar',
|
||||
terminalTabId: null,
|
||||
terminalLeafId: null
|
||||
})
|
||||
).toEqual({ kind: 'browser', pageId: 'page-1', target: 'address-bar' })
|
||||
})
|
||||
|
||||
it('falls back to the generic surface when a browser tab has no active page', () => {
|
||||
expect(
|
||||
resolveModalReturnFocusAction({
|
||||
tabType: 'browser',
|
||||
worktreeId: 'wt-1',
|
||||
browserPageId: null,
|
||||
browserTarget: 'webview',
|
||||
terminalTabId: null,
|
||||
terminalLeafId: null
|
||||
})
|
||||
).toEqual({ kind: 'surface' })
|
||||
})
|
||||
|
||||
it('restores a terminal tab through the scoped terminal focus path', () => {
|
||||
expect(
|
||||
resolveModalReturnFocusAction({
|
||||
tabType: 'terminal',
|
||||
worktreeId: 'wt-1',
|
||||
browserPageId: null,
|
||||
browserTarget: 'webview',
|
||||
terminalTabId: 'terminal-1',
|
||||
terminalLeafId: 'leaf-1'
|
||||
})
|
||||
).toEqual({ kind: 'terminal', tabId: 'terminal-1', leafId: 'leaf-1' })
|
||||
})
|
||||
|
||||
it('restores the editor surface before falling back to terminal focus', () => {
|
||||
expect(
|
||||
resolveModalReturnFocusAction({
|
||||
tabType: 'editor',
|
||||
worktreeId: 'wt-1',
|
||||
browserPageId: null,
|
||||
browserTarget: 'webview',
|
||||
terminalTabId: null,
|
||||
terminalLeafId: null
|
||||
})
|
||||
).toEqual({ kind: 'editor' })
|
||||
})
|
||||
|
||||
it('restores the simulator surface without using the terminal fallback', () => {
|
||||
expect(
|
||||
resolveModalReturnFocusAction({
|
||||
tabType: 'simulator',
|
||||
worktreeId: 'wt-1',
|
||||
browserPageId: null,
|
||||
browserTarget: 'webview',
|
||||
terminalTabId: null,
|
||||
terminalLeafId: null
|
||||
})
|
||||
).toEqual({ kind: 'simulator' })
|
||||
})
|
||||
|
||||
it('returns none when there is no worktree to restore into', () => {
|
||||
expect(
|
||||
resolveModalReturnFocusAction({
|
||||
tabType: 'terminal',
|
||||
worktreeId: null,
|
||||
browserPageId: null,
|
||||
browserTarget: 'webview',
|
||||
terminalTabId: null,
|
||||
terminalLeafId: null
|
||||
})
|
||||
).toEqual({ kind: 'none' })
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import type { BrowserFocusTarget } from '../components/browser-pane/browser-focus'
|
||||
|
||||
// The surface that held focus before a modal (QuickOpen, Cmd+J, ...) opened.
|
||||
// Captured at open time because Radix steals document focus once the dialog
|
||||
// mounts, so the raw activeElement is gone by close time.
|
||||
export type ModalReturnFocusSurface = {
|
||||
tabType: 'browser' | 'editor' | 'terminal' | 'simulator'
|
||||
worktreeId: string | null
|
||||
browserPageId: string | null
|
||||
browserTarget: BrowserFocusTarget
|
||||
terminalTabId: string | null
|
||||
terminalLeafId: string | null
|
||||
}
|
||||
|
||||
export type ModalReturnFocusAction =
|
||||
| { kind: 'browser'; pageId: string; target: BrowserFocusTarget }
|
||||
| { kind: 'terminal'; tabId: string; leafId: string | null }
|
||||
| { kind: 'editor' }
|
||||
| { kind: 'simulator' }
|
||||
| { kind: 'surface' }
|
||||
| { kind: 'none' }
|
||||
|
||||
// Why: a browser page lives in a separate webContents, so focus must route
|
||||
// through the browser focus request channel. Other surfaces need type-specific
|
||||
// DOM focus so a hidden xterm cannot steal focus from the active editor.
|
||||
export function resolveModalReturnFocusAction(
|
||||
captured: ModalReturnFocusSurface | null
|
||||
): ModalReturnFocusAction {
|
||||
if (!captured) {
|
||||
return { kind: 'none' }
|
||||
}
|
||||
if (captured.tabType === 'browser' && captured.browserPageId) {
|
||||
return { kind: 'browser', pageId: captured.browserPageId, target: captured.browserTarget }
|
||||
}
|
||||
if (captured.tabType === 'terminal' && captured.terminalTabId) {
|
||||
return { kind: 'terminal', tabId: captured.terminalTabId, leafId: captured.terminalLeafId }
|
||||
}
|
||||
if (captured.tabType === 'editor' && captured.worktreeId) {
|
||||
return { kind: 'editor' }
|
||||
}
|
||||
if (captured.tabType === 'simulator' && captured.worktreeId) {
|
||||
return { kind: 'simulator' }
|
||||
}
|
||||
if (captured.worktreeId) {
|
||||
return { kind: 'surface' }
|
||||
}
|
||||
return { kind: 'none' }
|
||||
}
|
||||
|
|
@ -0,0 +1,218 @@
|
|||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, useEffect } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useAppStore } from '../store'
|
||||
import { focusTerminalTabSurface } from '../lib/focus-terminal-tab-surface'
|
||||
import { ORCA_BROWSER_FOCUS_REQUEST_EVENT } from '../components/browser-pane/browser-focus'
|
||||
import { useModalReturnFocus } from './useModalReturnFocus'
|
||||
|
||||
vi.mock('../lib/focus-terminal-tab-surface', () => ({
|
||||
focusTerminalTabSurface: vi.fn()
|
||||
}))
|
||||
|
||||
let root: Root | null = null
|
||||
let container: HTMLDivElement | null = null
|
||||
let latestCaptureReturnFocus: (() => void) | null = null
|
||||
let latestSkipReturnFocus: (() => void) | null = null
|
||||
let nextAnimationFrameId = 1
|
||||
let animationFrames = new Map<number, FrameRequestCallback>()
|
||||
|
||||
function installAnimationFrameStubs(): void {
|
||||
nextAnimationFrameId = 1
|
||||
animationFrames = new Map()
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback): number => {
|
||||
const id = nextAnimationFrameId
|
||||
nextAnimationFrameId += 1
|
||||
animationFrames.set(id, callback)
|
||||
return id
|
||||
})
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number): void => {
|
||||
animationFrames.delete(id)
|
||||
})
|
||||
}
|
||||
|
||||
function flushAnimationFrames(): void {
|
||||
for (let i = 0; i < 10 && animationFrames.size > 0; i += 1) {
|
||||
const pending = Array.from(animationFrames.values())
|
||||
animationFrames.clear()
|
||||
for (const callback of pending) {
|
||||
callback(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Probe({ visible }: { visible: boolean }): null {
|
||||
const { captureReturnFocus, skipReturnFocus } = useModalReturnFocus(visible)
|
||||
useEffect(() => {
|
||||
latestCaptureReturnFocus = captureReturnFocus
|
||||
latestSkipReturnFocus = skipReturnFocus
|
||||
}, [captureReturnFocus, skipReturnFocus])
|
||||
return null
|
||||
}
|
||||
|
||||
async function renderProbe(visible: boolean): Promise<void> {
|
||||
if (!container) {
|
||||
container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
root = createRoot(container)
|
||||
}
|
||||
await act(async () => {
|
||||
root?.render(<Probe visible={visible} />)
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) {
|
||||
await act(async () => {
|
||||
root?.unmount()
|
||||
})
|
||||
}
|
||||
root = null
|
||||
container?.remove()
|
||||
container = null
|
||||
latestCaptureReturnFocus = null
|
||||
latestSkipReturnFocus = null
|
||||
document.body.innerHTML = ''
|
||||
useAppStore.setState({
|
||||
activeWorktreeId: null,
|
||||
activeTabType: 'terminal',
|
||||
activeTabId: null,
|
||||
activeTabIdByWorktree: {},
|
||||
activeBrowserTabId: null,
|
||||
browserTabsByWorktree: {},
|
||||
terminalLayoutsByTabId: {}
|
||||
})
|
||||
vi.unstubAllGlobals()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('useModalReturnFocus', () => {
|
||||
it('returns focus to the editor even when a terminal textarea is mounted first', async () => {
|
||||
installAnimationFrameStubs()
|
||||
useAppStore.setState({ activeWorktreeId: 'wt-1', activeTabType: 'editor' })
|
||||
const terminalTextarea = document.createElement('textarea')
|
||||
terminalTextarea.className = 'xterm-helper-textarea'
|
||||
document.body.append(terminalTextarea)
|
||||
const monaco = document.createElement('div')
|
||||
monaco.className = 'monaco-editor'
|
||||
const editorTextarea = document.createElement('textarea')
|
||||
monaco.append(editorTextarea)
|
||||
document.body.append(monaco)
|
||||
|
||||
await renderProbe(true)
|
||||
await renderProbe(false)
|
||||
flushAnimationFrames()
|
||||
|
||||
expect(document.activeElement).toBe(editorTextarea)
|
||||
expect(focusTerminalTabSurface).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns focus to the captured editor when multiple editors are mounted', async () => {
|
||||
installAnimationFrameStubs()
|
||||
useAppStore.setState({ activeWorktreeId: 'wt-1', activeTabType: 'editor' })
|
||||
const firstEditor = document.createElement('textarea')
|
||||
const firstMonaco = document.createElement('div')
|
||||
firstMonaco.className = 'monaco-editor'
|
||||
firstMonaco.append(firstEditor)
|
||||
document.body.append(firstMonaco)
|
||||
const secondEditor = document.createElement('textarea')
|
||||
const secondMonaco = document.createElement('div')
|
||||
secondMonaco.className = 'monaco-editor'
|
||||
secondMonaco.append(secondEditor)
|
||||
document.body.append(secondMonaco)
|
||||
|
||||
await renderProbe(false)
|
||||
secondEditor.focus()
|
||||
latestCaptureReturnFocus?.()
|
||||
await renderProbe(true)
|
||||
firstEditor.focus()
|
||||
await renderProbe(false)
|
||||
flushAnimationFrames()
|
||||
|
||||
expect(document.activeElement).toBe(secondEditor)
|
||||
})
|
||||
|
||||
it('captures browser address-bar focus before dialog autofocus moves focus', async () => {
|
||||
installAnimationFrameStubs()
|
||||
useAppStore.setState({
|
||||
activeWorktreeId: 'wt-1',
|
||||
activeTabType: 'browser',
|
||||
activeBrowserTabId: 'browser-1',
|
||||
browserTabsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'browser-1',
|
||||
worktreeId: 'wt-1',
|
||||
activePageId: 'page-1',
|
||||
pageIds: ['page-1'],
|
||||
url: 'https://example.com',
|
||||
title: 'Example',
|
||||
loading: false,
|
||||
faviconUrl: null,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
loadError: null,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
const addressBar = document.createElement('input')
|
||||
addressBar.dataset.orcaBrowserAddressBar = 'true'
|
||||
document.body.append(addressBar)
|
||||
const dialogInput = document.createElement('input')
|
||||
document.body.append(dialogInput)
|
||||
const focusRequests: unknown[] = []
|
||||
window.addEventListener(ORCA_BROWSER_FOCUS_REQUEST_EVENT, (event) => {
|
||||
focusRequests.push((event as CustomEvent).detail)
|
||||
})
|
||||
|
||||
await renderProbe(false)
|
||||
addressBar.focus()
|
||||
latestCaptureReturnFocus?.()
|
||||
await renderProbe(true)
|
||||
dialogInput.focus()
|
||||
await renderProbe(false)
|
||||
|
||||
expect(focusRequests).toEqual([{ pageId: 'page-1', target: 'address-bar' }])
|
||||
})
|
||||
|
||||
it('uses the scoped terminal focus helper for terminal surfaces', async () => {
|
||||
installAnimationFrameStubs()
|
||||
useAppStore.setState({
|
||||
activeWorktreeId: 'wt-1',
|
||||
activeTabType: 'terminal',
|
||||
activeTabId: 'terminal-global',
|
||||
activeTabIdByWorktree: { 'wt-1': 'terminal-1' },
|
||||
terminalLayoutsByTabId: {
|
||||
'terminal-1': { root: null, activeLeafId: 'leaf-1', expandedLeafId: null }
|
||||
}
|
||||
})
|
||||
|
||||
await renderProbe(true)
|
||||
await renderProbe(false)
|
||||
|
||||
expect(focusTerminalTabSurface).toHaveBeenCalledWith('terminal-1', 'leaf-1')
|
||||
})
|
||||
|
||||
it('skips return focus when the close action already moved focus', async () => {
|
||||
installAnimationFrameStubs()
|
||||
useAppStore.setState({ activeWorktreeId: 'wt-1', activeTabType: 'editor' })
|
||||
const monaco = document.createElement('div')
|
||||
monaco.className = 'monaco-editor'
|
||||
const editorTextarea = document.createElement('textarea')
|
||||
monaco.append(editorTextarea)
|
||||
document.body.append(monaco)
|
||||
|
||||
await renderProbe(true)
|
||||
latestSkipReturnFocus?.()
|
||||
await renderProbe(false)
|
||||
flushAnimationFrames()
|
||||
|
||||
expect(document.activeElement).not.toBe(editorTextarea)
|
||||
expect(focusTerminalTabSurface).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
import { useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
import { useAppStore } from '../store'
|
||||
import { focusTerminalTabSurface } from '../lib/focus-terminal-tab-surface'
|
||||
import {
|
||||
ORCA_BROWSER_FOCUS_REQUEST_EVENT,
|
||||
queueBrowserFocusRequest,
|
||||
type BrowserFocusRequestDetail
|
||||
} from '../components/browser-pane/browser-focus'
|
||||
import {
|
||||
resolveModalReturnFocusAction,
|
||||
type ModalReturnFocusSurface
|
||||
} from './modal-return-focus-action'
|
||||
|
||||
function isRestorableFocusedElement(element: HTMLElement | null): element is HTMLElement {
|
||||
return element !== null && element !== document.body && element !== document.documentElement
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores keyboard focus to the surface that was active before a modal opened.
|
||||
*
|
||||
* Why: Radix dialogs (QuickOpen, Cmd+J) prevent the default close-time focus
|
||||
* restoration to avoid landing on a stale trigger, but must then return focus
|
||||
* themselves — otherwise dismissing the dialog with Esc leaves the active
|
||||
* terminal/editor/browser panel unfocused. Capture happens on open because
|
||||
* Radix moves document focus into the dialog before the close fires.
|
||||
*/
|
||||
export function useModalReturnFocus(visible: boolean): {
|
||||
captureReturnFocus: () => void
|
||||
skipReturnFocus: () => void
|
||||
} {
|
||||
const capturedRef = useRef<ModalReturnFocusSurface | null>(null)
|
||||
const capturedElementRef = useRef<HTMLElement | null>(null)
|
||||
const skipRef = useRef(false)
|
||||
const wasVisibleRef = useRef(false)
|
||||
const outerFrameRef = useRef<number | null>(null)
|
||||
const innerFrameRef = useRef<number | null>(null)
|
||||
|
||||
const cancelFrames = useCallback((): void => {
|
||||
if (outerFrameRef.current !== null) {
|
||||
cancelAnimationFrame(outerFrameRef.current)
|
||||
outerFrameRef.current = null
|
||||
}
|
||||
if (innerFrameRef.current !== null) {
|
||||
cancelAnimationFrame(innerFrameRef.current)
|
||||
innerFrameRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => cancelFrames, [cancelFrames])
|
||||
|
||||
const focusCapturedElement = useCallback((): boolean => {
|
||||
const target = capturedElementRef.current
|
||||
if (!isRestorableFocusedElement(target) || !target.isConnected) {
|
||||
return false
|
||||
}
|
||||
target.focus()
|
||||
return document.activeElement === target || target.contains(document.activeElement)
|
||||
}, [])
|
||||
|
||||
const focusFirstMatchingSurface = useCallback(
|
||||
(selectors: string[]): void => {
|
||||
cancelFrames()
|
||||
outerFrameRef.current = requestAnimationFrame(() => {
|
||||
outerFrameRef.current = null
|
||||
innerFrameRef.current = requestAnimationFrame(() => {
|
||||
innerFrameRef.current = null
|
||||
for (const selector of selectors) {
|
||||
const target = document.querySelector(selector) as HTMLElement | null
|
||||
if (!target) {
|
||||
continue
|
||||
}
|
||||
target.focus()
|
||||
if (document.activeElement === target || target.contains(document.activeElement)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
[cancelFrames]
|
||||
)
|
||||
|
||||
// Why: a double rAF lets the dialog finish unmounting and the destination
|
||||
// surface settle before we focus it; editor surfaces own varied focusable DOM.
|
||||
const focusEditorSurface = useCallback((): void => {
|
||||
if (focusCapturedElement()) {
|
||||
return
|
||||
}
|
||||
focusFirstMatchingSurface([
|
||||
'.monaco-editor textarea',
|
||||
'.rich-markdown-editor[contenteditable="true"]',
|
||||
'.markdown-preview'
|
||||
])
|
||||
}, [focusCapturedElement, focusFirstMatchingSurface])
|
||||
|
||||
const focusSimulatorSurface = useCallback((): void => {
|
||||
if (focusCapturedElement()) {
|
||||
return
|
||||
}
|
||||
focusFirstMatchingSurface(['[data-orca-emulator-frame="true"] [tabindex]'])
|
||||
}, [focusCapturedElement, focusFirstMatchingSurface])
|
||||
|
||||
const focusFallbackSurface = useCallback((): void => {
|
||||
focusFirstMatchingSurface(['.xterm-helper-textarea', '.monaco-editor textarea'])
|
||||
}, [focusFirstMatchingSurface])
|
||||
|
||||
const requestBrowserFocus = useCallback((detail: BrowserFocusRequestDetail): void => {
|
||||
queueBrowserFocusRequest(detail)
|
||||
window.dispatchEvent(new CustomEvent(ORCA_BROWSER_FOCUS_REQUEST_EVENT, { detail }))
|
||||
}, [])
|
||||
|
||||
const captureReturnFocus = useCallback((): void => {
|
||||
const state = useAppStore.getState()
|
||||
const worktreeId = state.activeWorktreeId
|
||||
const tabType = state.activeTabType
|
||||
const activeElement =
|
||||
document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||
const browserPageId =
|
||||
worktreeId && tabType === 'browser'
|
||||
? ((state.browserTabsByWorktree[worktreeId] ?? []).find(
|
||||
(workspace) => workspace.id === state.activeBrowserTabId
|
||||
)?.activePageId ?? null)
|
||||
: null
|
||||
const terminalTabId =
|
||||
worktreeId && tabType === 'terminal'
|
||||
? (state.activeTabIdByWorktree[worktreeId] ?? state.activeTabId)
|
||||
: null
|
||||
const terminalLeafId = terminalTabId
|
||||
? (state.terminalLayoutsByTabId[terminalTabId]?.activeLeafId ?? null)
|
||||
: null
|
||||
// Why: this can be called from Radix onOpenAutoFocus, before focus moves
|
||||
// into the dialog, preserving address-bar/editor/simulator identity.
|
||||
const browserTarget =
|
||||
tabType === 'browser' && activeElement?.closest('[data-orca-browser-address-bar="true"]')
|
||||
? 'address-bar'
|
||||
: 'webview'
|
||||
capturedElementRef.current = isRestorableFocusedElement(activeElement) ? activeElement : null
|
||||
capturedRef.current = {
|
||||
tabType,
|
||||
worktreeId,
|
||||
browserPageId,
|
||||
browserTarget,
|
||||
terminalTabId,
|
||||
terminalLeafId
|
||||
}
|
||||
skipRef.current = false
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && !wasVisibleRef.current) {
|
||||
cancelFrames()
|
||||
if (!capturedRef.current) {
|
||||
captureReturnFocus()
|
||||
}
|
||||
skipRef.current = false
|
||||
}
|
||||
|
||||
if (!visible && wasVisibleRef.current) {
|
||||
const action = resolveModalReturnFocusAction(skipRef.current ? null : capturedRef.current)
|
||||
capturedRef.current = null
|
||||
if (action.kind === 'browser') {
|
||||
cancelFrames()
|
||||
requestBrowserFocus({ pageId: action.pageId, target: action.target })
|
||||
} else if (action.kind === 'terminal') {
|
||||
cancelFrames()
|
||||
focusTerminalTabSurface(action.tabId, action.leafId)
|
||||
} else if (action.kind === 'editor') {
|
||||
focusEditorSurface()
|
||||
} else if (action.kind === 'simulator') {
|
||||
focusSimulatorSurface()
|
||||
} else if (action.kind === 'surface') {
|
||||
focusFallbackSurface()
|
||||
}
|
||||
capturedElementRef.current = null
|
||||
}
|
||||
|
||||
wasVisibleRef.current = visible
|
||||
}, [
|
||||
visible,
|
||||
cancelFrames,
|
||||
captureReturnFocus,
|
||||
focusEditorSurface,
|
||||
focusFallbackSurface,
|
||||
focusSimulatorSurface,
|
||||
requestBrowserFocus
|
||||
])
|
||||
|
||||
// Why: callers invoke this when the close itself moves focus (e.g. opening a
|
||||
// file focuses the editor) so we don't yank focus back to the prior surface.
|
||||
const skipReturnFocus = useCallback((): void => {
|
||||
skipRef.current = true
|
||||
}, [])
|
||||
|
||||
return { captureReturnFocus, skipReturnFocus }
|
||||
}
|
||||
Loading…
Reference in New Issue