diff --git a/src/renderer/src/components/QuickOpen.tsx b/src/renderer/src/components/QuickOpen.tsx index b556b6ca3..d197094d5 100644 --- a/src/renderer/src/components/QuickOpen.tsx +++ b/src/renderer/src/components/QuickOpen.tsx @@ -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 ( { + if (!isActive) { + return + } + const handleBrowserFocusRequest = (event: Event): void => { + const detail = (event as CustomEvent).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({
{frameUrl ? ( diff --git a/src/renderer/src/hooks/modal-return-focus-action.test.ts b/src/renderer/src/hooks/modal-return-focus-action.test.ts new file mode 100644 index 000000000..ff2225011 --- /dev/null +++ b/src/renderer/src/hooks/modal-return-focus-action.test.ts @@ -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' }) + }) +}) diff --git a/src/renderer/src/hooks/modal-return-focus-action.ts b/src/renderer/src/hooks/modal-return-focus-action.ts new file mode 100644 index 000000000..d86487f9e --- /dev/null +++ b/src/renderer/src/hooks/modal-return-focus-action.ts @@ -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' } +} diff --git a/src/renderer/src/hooks/useModalReturnFocus.test.tsx b/src/renderer/src/hooks/useModalReturnFocus.test.tsx new file mode 100644 index 000000000..95aff445e --- /dev/null +++ b/src/renderer/src/hooks/useModalReturnFocus.test.tsx @@ -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() + +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 { + if (!container) { + container = document.createElement('div') + document.body.append(container) + root = createRoot(container) + } + await act(async () => { + root?.render() + }) +} + +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() + }) +}) diff --git a/src/renderer/src/hooks/useModalReturnFocus.ts b/src/renderer/src/hooks/useModalReturnFocus.ts new file mode 100644 index 000000000..02604c809 --- /dev/null +++ b/src/renderer/src/hooks/useModalReturnFocus.ts @@ -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(null) + const capturedElementRef = useRef(null) + const skipRef = useRef(false) + const wasVisibleRef = useRef(false) + const outerFrameRef = useRef(null) + const innerFrameRef = useRef(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 } +}