From e70842fe2c1052f3baf5babf03cc46b2a2f56005 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:10:08 -0700 Subject: [PATCH] fix(terminal): recover floating input after app refocus (#9233) * fix(terminal): recover floating input after app refocus * fix(terminal): preserve newer focus ownership --- .../FloatingTerminalPanel.test.tsx | 113 +++++++++++++++++- .../FloatingTerminalPanel.tsx | 68 ++++++++++- .../regular-terminal-focus-ownership.test.ts | 98 ++++++++++++++- .../regular-terminal-focus-ownership.ts | 26 +++- ...terminal-ime-input-context-refresh.test.ts | 63 +++++++++- .../terminal-ime-input-context-refresh.ts | 23 +++- .../lib/focus-terminal-tab-surface.test.ts | 53 ++++++++ .../src/lib/focus-terminal-tab-surface.ts | 41 ++++++- 8 files changed, 471 insertions(+), 14 deletions(-) diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx index dfcf8337e..a29c76f1d 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx @@ -106,12 +106,14 @@ const mocks = vi.hoisted(() => ({ getFloatingMarkdownDirectory: vi.fn(), getFloatingTerminalCwd: vi.fn(), getInstallStatus: vi.fn(), + isTerminalImeInputContextRefreshing: vi.fn(), isWebRuntimeSessionActive: vi.fn(), markFileDirty: vi.fn(), makePreviewFilePermanent: vi.fn(), openFile: vi.fn(), pickFloatingMarkdownDocument: vi.fn(), pinFile: vi.fn(), + setFloatingTerminalInputFocused: vi.fn(), setActiveTab: vi.fn(), setRenamingTabId: vi.fn(), setTabColor: vi.fn(), @@ -185,6 +187,10 @@ vi.mock('@/components/terminal-pane/TerminalPane', () => ({ } })) +vi.mock('@/components/terminal-pane/terminal-ime-input-context-refresh', () => ({ + isTerminalImeInputContextRefreshing: mocks.isTerminalImeInputContextRefreshing +})) + vi.mock('@/components/browser-pane/BrowserPane', () => ({ default: function BrowserPane() { return null @@ -744,6 +750,7 @@ describe('FloatingTerminalPanel close behavior', () => { mocks.getFloatingMarkdownDirectory.mockResolvedValue('/tmp/orca/floating-notes') mocks.getFloatingTerminalCwd.mockResolvedValue('/tmp/orca') mocks.getInstallStatus.mockResolvedValue({ state: 'installed', pathConfigured: true }) + mocks.isTerminalImeInputContextRefreshing.mockReturnValue(false) mocks.isWebRuntimeSessionActive.mockReturnValue(false) mocks.pickFloatingMarkdownDocument.mockResolvedValue(null) const localStorage = { @@ -762,7 +769,7 @@ describe('FloatingTerminalPanel close behavior', () => { }, browser: { notifyActiveTabChanged: vi.fn() }, cli: { getInstallStatus: mocks.getInstallStatus }, - ui: { setFloatingTerminalInputFocused: vi.fn() } + ui: { setFloatingTerminalInputFocused: mocks.setFloatingTerminalInputFocused } }, innerHeight: 800, innerWidth: 1200, @@ -799,6 +806,110 @@ describe('FloatingTerminalPanel close behavior', () => { expect(getPanelClassName(element)).toContain('z-30') }) + it('refreshes terminal native input focus when the floating panel opens', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' })]) + + await renderPanel(true) + runEffects() + + expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith( + 'tab-1', + null, + expect.objectContaining({ + onImeRefocusSkipped: expect.any(Function), + refreshImeContext: true + }) + ) + mocks.setFloatingTerminalInputFocused.mockClear() + mocks.focusTerminalTabSurface.mock.calls[0]?.[2].onImeRefocusSkipped() + expect(mocks.setFloatingTerminalInputFocused).toHaveBeenCalledWith(false) + + const newerFloatingInput = { + classList: { contains: (token: string) => token === 'xterm-helper-textarea' }, + closest: vi.fn().mockReturnValue({}) + } + Object.setPrototypeOf(newerFloatingInput, HTMLElement.prototype) + mocks.focusTerminalTabSurface.mock.calls[0]?.[2].onImeRefocusSkipped(newerFloatingInput) + expect(mocks.setFloatingTerminalInputFocused).toHaveBeenLastCalledWith(true) + }) + + it('preserves and reclaims terminal input ownership across window blur', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' })]) + const element = await renderPanel(true) + const panel = findByProp(element, 'data-floating-terminal-panel') + const panelElement = { contains: vi.fn().mockReturnValue(true), focus: vi.fn() } + const terminalInput = { + blur: vi.fn(), + classList: { contains: vi.fn((token: string) => token === 'xterm-helper-textarea') }, + closest: vi.fn((selector: string) => { + if (selector === '[data-floating-terminal-panel]') { + return panelElement + } + return selector === '[data-leaf-id]' + ? { + getAttribute: (attribute: string) => (attribute === 'data-leaf-id' ? 'leaf-1' : null) + } + : null + }), + isConnected: true + } + Object.setPrototypeOf(panelElement, HTMLElement.prototype) + Object.setPrototypeOf(terminalInput, HTMLElement.prototype) + attachRef(panel.props.ref, panelElement) + mocks.isTerminalImeInputContextRefreshing.mockReturnValueOnce(true) + const onBlurCapture = panel.props.onBlurCapture as (event: unknown) => void + onBlurCapture({ + relatedTarget: null, + target: terminalInput + }) + expect(mocks.setFloatingTerminalInputFocused).not.toHaveBeenCalled() + const documentState = { + activeElement: terminalInput as unknown as HTMLElement | null, + addEventListener: vi.fn(), + body: {} as HTMLElement, + removeEventListener: vi.fn() + } + vi.stubGlobal('document', documentState) + runEffects() + const blurListener = vi + .mocked(window.addEventListener) + .mock.calls.findLast(([type]) => type === 'blur')?.[1] as (() => void) | undefined + const focusListener = vi + .mocked(window.addEventListener) + .mock.calls.find(([type]) => type === 'focus')?.[1] as (() => void) | undefined + if (!blurListener || !focusListener) { + throw new Error('floating terminal window focus listeners not registered') + } + + blurListener() + expect(terminalInput.blur).not.toHaveBeenCalled() + + focusListener() + expect(mocks.setFloatingTerminalInputFocused).toHaveBeenCalledWith(true) + + documentState.activeElement = terminalInput as unknown as HTMLElement + blurListener() + documentState.activeElement = documentState.body + mocks.focusTerminalTabSurface.mockClear() + focusListener() + expect(mocks.focusTerminalTabSurface).not.toHaveBeenCalled() + + documentState.activeElement = terminalInput as unknown as HTMLElement + blurListener() + terminalInput.isConnected = false + documentState.activeElement = documentState.body + focusListener() + expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith( + 'tab-1', + 'leaf-1', + expect.objectContaining({ + onlyIfFocusUnclaimed: true, + onImeRefocusSkipped: expect.any(Function), + refreshImeContext: true + }) + ) + }) + it('falls back to default bounds when persisted geometry is malformed', async () => { getMockedLocalStorage().getItem.mockImplementation((key: string) => key === FLOATING_TERMINAL_PANEL_BOUNDS_STORAGE_KEY diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx index 4f0b65ea3..c5b509264 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx @@ -12,6 +12,7 @@ import { useContextualTour } from '@/components/contextual-tours/use-contextual- import TabBar from '@/components/tab-bar/TabBar' import { resolveGroupTabFromVisibleId } from '@/components/tab-group/tab-group-visible-id' import TerminalPane from '@/components/terminal-pane/TerminalPane' +import { isTerminalImeInputContextRefreshing } from '@/components/terminal-pane/terminal-ime-input-context-refresh' import { Button } from '@/components/ui/button' import { useMountedRef } from '@/hooks/useMountedRef' import { useShortcutKeyDetails, type ShortcutKeyComboDetails } from '@/hooks/useShortcutLabel' @@ -222,6 +223,10 @@ export function FloatingTerminalPanel({ } const shortcutFocusFrameRef = useRef(null) const shortcutFocusTimeoutRef = useRef(null) + const reclaimTerminalInputOnWindowFocusRef = useRef<{ + helper: HTMLElement + leafId: string | null + } | null>(null) const mountedRef = useMountedRef() const dragRef = useRef<{ pointerId: number @@ -578,7 +583,11 @@ export function FloatingTerminalPanel({ if (!open || !activeTerminalId) { return } - focusTerminalTabSurface(activeTerminalId) + focusTerminalTabSurface(activeTerminalId, null, { + onImeRefocusSkipped: (active) => + setFloatingTerminalInputFocusedInMain(isFloatingWorkspaceTerminalInputTarget(active)), + refreshImeContext: true + }) }, [activeTerminalId, open]) useEffect(() => { @@ -1313,22 +1322,69 @@ export function FloatingTerminalPanel({ const handleWindowBlur = (): void => { const panel = panelRef.current const active = document.activeElement + reclaimTerminalInputOnWindowFocusRef.current = null if (!panel || !(active instanceof HTMLElement) || !panel.contains(active)) { return } // Why: browser webviews focus out-of-process and do not emit renderer // pointerdown events, so release floating ownership on renderer blur too. setFloatingTerminalInputFocusedInMain(false) + if (isFloatingWorkspaceTerminalInputTarget(active)) { + // Why: the terminal focus lifecycle preserves this exact helper across + // app blur so macOS can rebuild its native input context on return. + reclaimTerminalInputOnWindowFocusRef.current = { + helper: active, + leafId: active.closest('[data-leaf-id]')?.getAttribute('data-leaf-id') ?? null + } + return + } active.blur() } + const handleWindowFocus = (): void => { + const reclaim = reclaimTerminalInputOnWindowFocusRef.current + if (!reclaim) { + return + } + reclaimTerminalInputOnWindowFocusRef.current = null + const panel = panelRef.current + const active = document.activeElement + if ( + panel && + active instanceof HTMLElement && + panel.contains(active) && + isFloatingWorkspaceTerminalInputTarget(active) + ) { + setFloatingTerminalInputFocusedInMain(true) + return + } + if ((active === null || active === document.body) && activeTerminalId) { + if (reclaim.helper.isConnected && panel?.contains(reclaim.helper)) { + // Why: TerminalPane owns exact-helper reclaim and IME refresh. Avoid + // racing it with a second floating-layer blur/refocus cycle. + return + } + // Why: only a helper that genuinely remounted while backgrounded needs + // tab/leaf recovery; the shared TerminalPane owner cannot reclaim it. + focusTerminalTabSurface(activeTerminalId, reclaim.leafId, { + onlyIfFocusUnclaimed: true, + onImeRefocusSkipped: (active) => + setFloatingTerminalInputFocusedInMain(isFloatingWorkspaceTerminalInputTarget(active)), + refreshImeContext: true + }) + } + } + document.addEventListener('pointerdown', handleOutsidePointerDown, true) window.addEventListener('blur', handleWindowBlur) + window.addEventListener('focus', handleWindowFocus) return () => { + reclaimTerminalInputOnWindowFocusRef.current = null document.removeEventListener('pointerdown', handleOutsidePointerDown, true) window.removeEventListener('blur', handleWindowBlur) + window.removeEventListener('focus', handleWindowFocus) } - }, [open]) + }, [activeTerminalId, open]) const handleDragStart = (event: React.PointerEvent): void => { if (maximized) { return @@ -1423,7 +1479,13 @@ export function FloatingTerminalPanel({ commitUserBounds({ ...stagedBoundsRef.current, width: rect.width, height: rect.height }) }} onFocusCapture={(event) => setFloatingTerminalInputFocused(event.target)} - onBlurCapture={(event) => setFloatingTerminalInputFocused(event.relatedTarget)} + onBlurCapture={(event) => { + // Why: keep terminal-first shortcut ownership latched during the + // synchronous macOS IME refresh blur; refocus or its skip callback settles it. + if (!isTerminalImeInputContextRefreshing(event.target)) { + setFloatingTerminalInputFocused(event.relatedTarget) + } + }} onKeyDownCapture={handleShortcutSurfaceKeyDown} >
diff --git a/src/renderer/src/components/terminal-pane/regular-terminal-focus-ownership.test.ts b/src/renderer/src/components/terminal-pane/regular-terminal-focus-ownership.test.ts index 516a19f39..c10f41498 100644 --- a/src/renderer/src/components/terminal-pane/regular-terminal-focus-ownership.test.ts +++ b/src/renderer/src/components/terminal-pane/regular-terminal-focus-ownership.test.ts @@ -154,12 +154,13 @@ describe('regular terminal focus ownership', () => { }) expect(synced).toBe(true) - expect(syncFocused).toHaveBeenCalledWith(true) + expect(syncFocused).not.toHaveBeenCalled() // Why: reclaim is deferred so a newer focus owner during reactivation wins. expect(focus).not.toHaveBeenCalled() for (const run of scheduled) { run() } + expect(syncFocused).toHaveBeenCalledWith(true) expect(focus).toHaveBeenCalledOnce() expect(document.activeElement).toBe(helper) }) @@ -203,6 +204,39 @@ describe('regular terminal focus ownership', () => { expect(document.activeElement).toBe(secondHelper) }) + it('keeps ownership clear when the released helper cannot accept focus', () => { + const pane = appendPane() + const helper = appendHelper(pane) + const syncFocused = vi.fn() + helper.focus() + + const releasedHelper = releaseTerminalFocusForWindowBlur({ + container: pane, + activeElement: helper, + syncFocused + }) + document.body.focus() + syncFocused.mockClear() + vi.spyOn(helper, 'focus').mockImplementation(() => undefined) + const scheduled: (() => void)[] = [] + + resyncTerminalFocusForWindowFocus({ + container: pane, + activeElement: document.activeElement, + syncFocused, + releasedHelper, + isMac: false, + scheduleRefocus: (callback) => scheduled.push(callback) + }) + for (const run of scheduled) { + run() + } + + expect(document.activeElement).toBe(document.body) + expect(syncFocused).toHaveBeenCalledOnce() + expect(syncFocused).toHaveBeenCalledWith(false) + }) + it('does not yank focus back into the terminal if the user clicked elsewhere during reactivation', () => { // Why: the Linux reclaim path must honor the same "newer focus owner wins" // guard the macOS path uses, so a click into the sidebar/dialog isn't stolen. @@ -220,6 +254,7 @@ describe('regular terminal focus ownership', () => { syncFocused }) document.body.focus() + syncFocused.mockClear() focus.mockClear() const scheduled: (() => void)[] = [] @@ -238,9 +273,44 @@ describe('regular terminal focus ownership', () => { } expect(focus).not.toHaveBeenCalled() + expect(syncFocused).toHaveBeenCalledWith(false) expect(document.activeElement).toBe(outside) }) + it('does not clear ownership published by a newer terminal during deferred reclaim', () => { + const pane = appendPane() + const helper = appendHelper(pane) + const newerPane = appendPane() + const newerHelper = appendHelper(newerPane) + const syncFocused = vi.fn() + helper.focus() + + const releasedHelper = releaseTerminalFocusForWindowBlur({ + container: pane, + activeElement: helper, + syncFocused + }) + document.body.focus() + syncFocused.mockClear() + const scheduled: (() => void)[] = [] + + resyncTerminalFocusForWindowFocus({ + container: pane, + activeElement: document.activeElement, + syncFocused, + releasedHelper, + isMac: false, + scheduleRefocus: (callback) => scheduled.push(callback) + }) + newerHelper.focus() + for (const run of scheduled) { + run() + } + + expect(document.activeElement).toBe(newerHelper) + expect(syncFocused).not.toHaveBeenCalled() + }) + it('does not reclaim a released helper that was detached from the DOM before refocus', () => { const pane = appendPane() const helper = appendHelper(pane) @@ -355,9 +425,35 @@ describe('regular terminal focus ownership', () => { run() } expect(focus).not.toHaveBeenCalled() + expect(syncFocused).toHaveBeenLastCalledWith(false) expect(document.activeElement).toBe(outside) }) + it('does not clear ownership published by a newer terminal during IME refresh', () => { + const pane = appendPane() + const helper = appendHelper(pane) + const newerHelper = appendHelper(appendPane()) + const syncFocused = vi.fn() + helper.focus() + const scheduled: (() => void)[] = [] + + resyncTerminalFocusForWindowFocus({ + container: pane, + activeElement: document.activeElement, + syncFocused, + isMac: true, + scheduleRefocus: (callback) => scheduled.push(callback) + }) + syncFocused.mockClear() + newerHelper.focus() + for (const run of scheduled) { + run() + } + + expect(document.activeElement).toBe(newerHelper) + expect(syncFocused).not.toHaveBeenCalled() + }) + it('skips the blur/refocus cycle on non-macOS platforms', () => { const pane = appendPane() const helper = appendHelper(pane) diff --git a/src/renderer/src/components/terminal-pane/regular-terminal-focus-ownership.ts b/src/renderer/src/components/terminal-pane/regular-terminal-focus-ownership.ts index 3f50f574f..768b0dfb3 100644 --- a/src/renderer/src/components/terminal-pane/regular-terminal-focus-ownership.ts +++ b/src/renderer/src/components/terminal-pane/regular-terminal-focus-ownership.ts @@ -102,8 +102,6 @@ export function resyncTerminalFocusForWindowFocus(args: { } } - args.syncFocused(true) - const reclaimedHelper = helper // Why: defer the reclaim refocus to the next frame and only take focus if @@ -115,6 +113,7 @@ export function resyncTerminalFocusForWindowFocus(args: { const schedule = args.scheduleRefocus ?? scheduleNextFrame schedule(() => { if (!reclaimedHelper.isConnected) { + syncFocusAfterFailedReclaim(reclaimedHelper.ownerDocument.activeElement, args.syncFocused) return } const active = reclaimedHelper.ownerDocument.activeElement @@ -123,21 +122,44 @@ export function resyncTerminalFocusForWindowFocus(args: { isDocumentBodyOrNull(active, reclaimedHelper.ownerDocument) ) { reclaimedHelper.focus() + if (reclaimedHelper.ownerDocument.activeElement === reclaimedHelper) { + args.syncFocused(true) + } else { + syncFocusAfterFailedReclaim(reclaimedHelper.ownerDocument.activeElement, args.syncFocused) + } + return } + syncFocusAfterFailedReclaim(active, args.syncFocused) }) return true } + args.syncFocused(true) + // Why: macOS app reactivation leaves a stale NSTextInputContext on the // still-focused helper (electron#32307/#34952); non-mac returns false inside. refreshTerminalImeInputContext(reclaimedHelper, { isMac: args.isMac, + // Why: if another control wins during the refresh frame, the terminal + // mirror must follow that owner instead of remaining latched true. + onRefocusSkipped: (active) => syncFocusAfterFailedReclaim(active, args.syncFocused), scheduleRefocus: args.scheduleRefocus }) return true } +function syncFocusAfterFailedReclaim( + activeElement: Element | null, + syncFocused: TerminalInputFocusSync +): void { + // Why: a later xterm focusin already published terminal ownership; an older + // deferred reclaim must not overwrite that newer process-wide mirror. + if (!isXtermHelperTextarea(activeElement)) { + syncFocused(false) + } +} + function isNode(value: EventTarget | null): value is Node { return typeof Node !== 'undefined' && value instanceof Node } diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-input-context-refresh.test.ts b/src/renderer/src/components/terminal-pane/terminal-ime-input-context-refresh.test.ts index 69b73c586..5a31b691c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-input-context-refresh.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-input-context-refresh.test.ts @@ -1,6 +1,9 @@ // @vitest-environment happy-dom import { beforeEach, describe, expect, it, vi } from 'vitest' -import { refreshTerminalImeInputContext } from './terminal-ime-input-context-refresh' +import { + isTerminalImeInputContextRefreshing, + refreshTerminalImeInputContext +} from './terminal-ime-input-context-refresh' describe('refreshTerminalImeInputContext', () => { beforeEach(() => { @@ -38,6 +41,23 @@ describe('refreshTerminalImeInputContext', () => { expect(document.activeElement).toBe(helper) }) + it('marks only the synchronous refresh blur so focus ownership can stay latched', () => { + const helper = appendHelper() + let refreshingDuringBlur = false + helper.addEventListener('blur', (event) => { + refreshingDuringBlur = isTerminalImeInputContextRefreshing(event.target) + }) + helper.focus() + + refreshTerminalImeInputContext(helper, { + isMac: true, + scheduleRefocus: vi.fn() + }) + + expect(refreshingDuringBlur).toBe(true) + expect(isTerminalImeInputContextRefreshing(helper)).toBe(false) + }) + it('does not steal focus if another element grabbed it before the refocus frame', () => { const helper = appendHelper() const outside = document.createElement('input') @@ -60,6 +80,47 @@ describe('refreshTerminalImeInputContext', () => { expect(document.activeElement).toBe(outside) }) + it('reports when a newer focus owner wins the refocus guard', () => { + const helper = appendHelper() + const outside = document.createElement('input') + document.body.appendChild(outside) + const onRefocusSkipped = vi.fn() + const scheduled: (() => void)[] = [] + helper.focus() + + refreshTerminalImeInputContext(helper, { + isMac: true, + onRefocusSkipped, + scheduleRefocus: (callback) => scheduled.push(callback) + }) + + outside.focus() + for (const run of scheduled) { + run() + } + expect(onRefocusSkipped).toHaveBeenCalledWith(outside) + }) + + it('reports when a connected helper cannot accept the scheduled focus', () => { + const helper = appendHelper() + const onRefocusSkipped = vi.fn() + const scheduled: (() => void)[] = [] + helper.focus() + + refreshTerminalImeInputContext(helper, { + isMac: true, + onRefocusSkipped, + scheduleRefocus: (callback) => scheduled.push(callback) + }) + vi.spyOn(helper, 'focus').mockImplementation(() => undefined) + for (const run of scheduled) { + run() + } + + expect(document.activeElement).toBe(document.body) + expect(onRefocusSkipped).toHaveBeenCalledWith(document.body) + }) + it('skips non-macOS platforms', () => { const helper = appendHelper() const blur = vi.spyOn(helper, 'blur') diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-input-context-refresh.ts b/src/renderer/src/components/terminal-pane/terminal-ime-input-context-refresh.ts index e86fc79bd..debb3f39c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-input-context-refresh.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-input-context-refresh.ts @@ -3,10 +3,18 @@ export type TerminalImeInputContextRefocusScheduler = (callback: () => void) => export type TerminalImeInputContextRefreshOptions = { /** Override the macOS check (tests). Defaults to the navigator user agent. */ isMac?: boolean + /** Called with the settled owner when the scheduled refocus does not land. */ + onRefocusSkipped?: (activeElement: Element | null) => void /** Override the refocus scheduler (tests). Defaults to requestAnimationFrame. */ scheduleRefocus?: TerminalImeInputContextRefocusScheduler } +const refreshingHelpers = new WeakSet() + +export function isTerminalImeInputContextRefreshing(target: EventTarget | null): boolean { + return target instanceof HTMLElement && refreshingHelpers.has(target) +} + function isMacUserAgent(): boolean { return typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac') } @@ -38,17 +46,28 @@ export function refreshTerminalImeInputContext( const ownerDocument = helper.ownerDocument // Why: Electron/Chromium can keep a stale NSTextInputContext on the xterm // helper after focus handoffs; blur/refocus rebuilds it so CJK IMEs work. - helper.blur() + refreshingHelpers.add(helper) + try { + helper.blur() + } finally { + refreshingHelpers.delete(helper) + } const schedule = options.scheduleRefocus ?? scheduleNextFrame schedule(() => { - const active = ownerDocument.activeElement if (!helper.isConnected) { + options.onRefocusSkipped?.(ownerDocument.activeElement) return } + const active = ownerDocument.activeElement if (active === helper || isDocumentBodyOrNull(active, ownerDocument)) { helper.focus() + if (ownerDocument.activeElement !== helper) { + options.onRefocusSkipped?.(ownerDocument.activeElement) + } + return } + options.onRefocusSkipped?.(active) }) return true diff --git a/src/renderer/src/lib/focus-terminal-tab-surface.test.ts b/src/renderer/src/lib/focus-terminal-tab-surface.test.ts index 9d2313da4..9df0ef413 100644 --- a/src/renderer/src/lib/focus-terminal-tab-surface.test.ts +++ b/src/renderer/src/lib/focus-terminal-tab-surface.test.ts @@ -1,8 +1,17 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { focusTerminalTabSurface } from './focus-terminal-tab-surface' +const mocks = vi.hoisted(() => ({ + refreshTerminalImeInputContext: vi.fn() +})) + +vi.mock('@/components/terminal-pane/terminal-ime-input-context-refresh', () => ({ + refreshTerminalImeInputContext: mocks.refreshTerminalImeInputContext +})) + describe('focusTerminalTabSurface', () => { afterEach(() => { + mocks.refreshTerminalImeInputContext.mockClear() vi.unstubAllGlobals() }) @@ -28,6 +37,50 @@ describe('focusTerminalTabSurface', () => { expect(textarea.focus).toHaveBeenCalled() }) + it('optionally refreshes the focused helper native input context', () => { + flushAnimationFrames() + const textarea = { focus: vi.fn() } + vi.stubGlobal('document', { + querySelector: vi.fn((selector: string) => + selector === '[data-terminal-tab-id="tab-1"] .xterm-helper-textarea' ? textarea : null + ) + }) + + focusTerminalTabSurface('tab-1', null, { refreshImeContext: true }) + + expect(textarea.focus).toHaveBeenCalledOnce() + expect(mocks.refreshTerminalImeInputContext).toHaveBeenCalledWith(textarea, { + onRefocusSkipped: undefined + }) + }) + + it('does not steal focus from a newer owner during guarded remount recovery', () => { + const frames: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frames.push(callback) + return frames.length + }) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + const textarea = { focus: vi.fn() } + const body = {} + const outside = {} + const documentState = { + activeElement: body as unknown, + body, + querySelector: vi.fn((selector: string) => + selector === '[data-terminal-tab-id="tab-1"] .xterm-helper-textarea' ? textarea : null + ) + } + vi.stubGlobal('document', documentState) + + focusTerminalTabSurface('tab-1', null, { onlyIfFocusUnclaimed: true }) + frames.shift()?.(0) + documentState.activeElement = outside + frames.shift()?.(0) + + expect(textarea.focus).not.toHaveBeenCalled() + }) + it('does not steal focus while inline tab rename is open', () => { flushAnimationFrames() const textarea = { focus: vi.fn() } diff --git a/src/renderer/src/lib/focus-terminal-tab-surface.ts b/src/renderer/src/lib/focus-terminal-tab-surface.ts index a41913c2a..97ceb97ae 100644 --- a/src/renderer/src/lib/focus-terminal-tab-surface.ts +++ b/src/renderer/src/lib/focus-terminal-tab-surface.ts @@ -1,3 +1,5 @@ +import { refreshTerminalImeInputContext } from '@/components/terminal-pane/terminal-ime-input-context-refresh' + /** * Move keyboard focus into the xterm instance for a freshly-mounted terminal * tab. Handles the two-step race where React must first mount the new @@ -11,6 +13,29 @@ function cssAttributeString(value: string): string { let pendingFocusFrameIds: number[] = [] +type FocusTerminalTabSurfaceOptions = { + onlyIfFocusUnclaimed?: boolean + onImeRefocusSkipped?: (activeElement: Element | null) => void + refreshImeContext?: boolean +} + +function focusTerminalHelper(helper: HTMLElement, options: FocusTerminalTabSurfaceOptions): void { + if (options.onlyIfFocusUnclaimed) { + const active = document.activeElement + if (active !== helper && active !== null && active !== document.body) { + return + } + } + helper.focus() + if (options.refreshImeContext) { + // Why: a CSS-hidden, long-lived xterm can retain a stale macOS native text + // input context even after DOM focus returns; blur/refocus rebuilds it. + refreshTerminalImeInputContext(helper, { + onRefocusSkipped: options.onImeRefocusSkipped + }) + } +} + function cancelPendingFocusFrames(): void { if (typeof cancelAnimationFrame === 'function') { for (const frameId of pendingFocusFrameIds) { @@ -29,7 +54,11 @@ function canUseSinglePaneStaleLeafFallback(tabId: string, leafId: string): boole return expectedLeafIds?.length === 1 && !expectedLeafIds.includes(leafId) } -export function focusTerminalTabSurface(tabId: string, leafId?: string | null): void { +export function focusTerminalTabSurface( + tabId: string, + leafId?: string | null, + options: FocusTerminalTabSurfaceOptions = {} +): void { cancelPendingFocusFrames() const firstFrameId = requestAnimationFrame(() => { pendingFocusFrameIds = pendingFocusFrameIds.filter((frameId) => frameId !== firstFrameId) @@ -46,7 +75,7 @@ export function focusTerminalTabSurface(tabId: string, leafId?: string | null): : `[data-terminal-tab-id="${escapedTabId}"] .xterm-helper-textarea` const scoped = document.querySelector(scopedSelector) as HTMLElement | null if (scoped) { - scoped.focus() + focusTerminalHelper(scoped, options) return } if (leafId) { @@ -62,13 +91,17 @@ export function focusTerminalTabSurface(tabId: string, leafId?: string | null): ) if (tabScopedHelpers.length === 1) { const fallback = tabScopedHelpers.item(0) as HTMLElement | null - fallback?.focus() + if (fallback) { + focusTerminalHelper(fallback, options) + } return } return } const fallback = document.querySelector('.xterm-helper-textarea') as HTMLElement | null - fallback?.focus() + if (fallback) { + focusTerminalHelper(fallback, options) + } }) pendingFocusFrameIds.push(secondFrameId) })