fix(terminal): recover floating input after app refocus (#9233)
* fix(terminal): recover floating input after app refocus * fix(terminal): preserve newer focus ownership
This commit is contained in:
parent
e6933fed84
commit
e70842fe2c
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<number | null>(null)
|
||||
const shortcutFocusTimeoutRef = useRef<number | null>(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<HTMLDivElement>): 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}
|
||||
>
|
||||
<div className="relative flex h-full w-full min-h-0 flex-col overflow-hidden rounded-lg border border-black/14 bg-card dark:border-white/14">
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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<HTMLElement>()
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue