mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
fix(terminal): defer a focus-cancelled paste instead of discarding it (STA-5272)
A paste cancelled because focus left the pane threw the clipboard payload away. Any dictation tool, IME, overlay, or clipboard-manager popup takes focus for a moment and gives it back, so a routine focus blip destroyed a paragraph of dictated text with no way to get it back. Three changes, all on the renderer paste path: - A paste the focus guard stops is now held for the pane it was aimed at and delivered the moment that pane has focus again, bounded at 2s. Focus landing in a *different* pane still cancels immediately, which is the wrong-target case the guard exists for. After the deadline the payload is released and the user gets a persistent, platform-correct instruction for pasting again. - A chord that arrives while focus sits on <body> - exactly what a dictation overlay leaves behind - now reaches the pane. The pane's listeners are bound to its own container with capture, so a body-targeted keydown never reached them: no paste, no error, nothing. A document-level fallback recovers it, but only while the pane is the last surface to have held focus and neither another element nor the pointer has claimed it since. - A clipboard text read that FAILS is now reported as unavailable rather than coerced into "empty clipboard". Image-only clipboards still fall through to the image path unchanged; only a failure with no image behind it now produces a message instead of silence. Refs STA-3834
This commit is contained in:
@@ -222,8 +222,23 @@ import {
|
||||
type TerminalPasteSource,
|
||||
type TerminalPasteTextOptions
|
||||
} from './terminal-paste-coordinator'
|
||||
import {
|
||||
createDeferredPasteFocusInHandler,
|
||||
createDeferredTerminalPasteQueue,
|
||||
isDeferrablePasteFocusCancellation,
|
||||
isFocusInsideOtherPane,
|
||||
type DeferredTerminalPasteQueue
|
||||
} from './terminal-deferred-paste'
|
||||
import { appendTerminalErrorMessage } from './terminal-error-accumulation'
|
||||
import { formatTerminalPasteExecutionError } from './terminal-paste-errors'
|
||||
import {
|
||||
installInertFocusPasteFallback,
|
||||
restoreInertFocusPasteTarget
|
||||
} from './terminal-inert-focus-paste-fallback'
|
||||
import {
|
||||
formatDeferredTerminalPasteDroppedError,
|
||||
formatTerminalPasteExecutionError,
|
||||
TERMINAL_CLIPBOARD_READ_UNAVAILABLE_MESSAGE
|
||||
} from './terminal-paste-errors'
|
||||
import { resolveTerminalPasteRuntime } from './terminal-paste-runtime'
|
||||
import { getTerminalPasteSshRemotePlatform } from './terminal-paste-ssh-platform'
|
||||
import {
|
||||
@@ -402,6 +417,15 @@ function TerminalPane(
|
||||
const [agentSessionContinuation, setAgentSessionContinuation] =
|
||||
useState<AgentSessionContinuationRequest | null>(null)
|
||||
const [terminalError, setTerminalError] = useState<string | null>(null)
|
||||
// Why: outlives the paste effect so a keybinding change mid-deferral does not
|
||||
// silently drop the payload; only unmount does.
|
||||
const deferredPasteRef = useRef<DeferredTerminalPasteQueue | null>(null)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
deferredPasteRef.current?.dispose()
|
||||
deferredPasteRef.current = null
|
||||
}
|
||||
}, [])
|
||||
const [paneProcessExitsByPaneId, setPaneProcessExitsByPaneId] = useState<
|
||||
Record<number, PaneProcessExit>
|
||||
>({})
|
||||
@@ -1949,12 +1973,25 @@ function TerminalPane(
|
||||
})
|
||||
}
|
||||
|
||||
const getDeferredPasteQueue = (): DeferredTerminalPasteQueue => {
|
||||
if (!deferredPasteRef.current) {
|
||||
deferredPasteRef.current = createDeferredTerminalPasteQueue({
|
||||
onExpire: () =>
|
||||
setTerminalError(formatDeferredTerminalPasteDroppedError(shortcutPlatform))
|
||||
})
|
||||
}
|
||||
return deferredPasteRef.current
|
||||
}
|
||||
|
||||
const executePanePasteText = async (
|
||||
pane: ManagedPane,
|
||||
source: TerminalPasteSource,
|
||||
activeElementAtDispatch: Element | null,
|
||||
text: string,
|
||||
options?: TerminalPasteTextOptions
|
||||
options?: TerminalPasteTextOptions,
|
||||
// Why: a redelivered paste never re-defers, so a pane that keeps refusing
|
||||
// focus cannot loop the payload past its deadline.
|
||||
allowDeferOnFocusLoss = true
|
||||
): Promise<void> => {
|
||||
const connectionId = getConnectionId(worktreeId) ?? null
|
||||
const transport = paneTransportsRef.current.get(pane.id)
|
||||
@@ -2000,6 +2037,28 @@ function TerminalPane(
|
||||
canContinue: () => isPanePasteTargetMounted(pane, transport, ptyId)
|
||||
})
|
||||
if (execution.status !== 'pasted') {
|
||||
if (
|
||||
allowDeferOnFocusLoss &&
|
||||
isDeferrablePasteFocusCancellation({
|
||||
status: execution.status,
|
||||
reason: execution.reason,
|
||||
targetMounted: isPanePasteTargetMounted(pane, transport, ptyId),
|
||||
focusMovedToOtherPane: isFocusInsideOtherPane({
|
||||
panes: managerRef.current?.getPanes() ?? [],
|
||||
paneId: pane.id,
|
||||
focusedElement: document.activeElement
|
||||
})
|
||||
})
|
||||
) {
|
||||
getDeferredPasteQueue().defer({
|
||||
paneId: pane.id,
|
||||
leafId: pane.leafId,
|
||||
source,
|
||||
text,
|
||||
options
|
||||
})
|
||||
return
|
||||
}
|
||||
setTerminalError(formatTerminalPasteExecutionError(execution.reason))
|
||||
return
|
||||
}
|
||||
@@ -2055,7 +2114,9 @@ function TerminalPane(
|
||||
executePanePasteText(pane, source, activeElementAtDispatch, text, options),
|
||||
onTextPasteError: () =>
|
||||
setTerminalError('Paste failed: clipboard text is too large for a safe terminal paste.'),
|
||||
onImagePasteError: (error) => setTerminalError(formatClipboardImagePasteError(error))
|
||||
onImagePasteError: (error) => setTerminalError(formatClipboardImagePasteError(error)),
|
||||
onClipboardReadUnavailable: () =>
|
||||
setTerminalError(TERMINAL_CLIPBOARD_READ_UNAVAILABLE_MESSAGE)
|
||||
}).catch(() => {
|
||||
setTerminalError('Paste failed.')
|
||||
})
|
||||
@@ -2071,7 +2132,19 @@ function TerminalPane(
|
||||
(!isMac && e.key === 'Insert' && e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey)
|
||||
)
|
||||
}
|
||||
const onKeyPaste = (e: KeyboardEvent): void => {
|
||||
// Why: focus sits on <body> after a dictation/IME/overlay handoff, so the pane's
|
||||
// own dispatch-element guard would read this paste as aimed somewhere else.
|
||||
// Put focus back on the pane that still owns it before the shared path runs.
|
||||
const restoreInertPaneFocus = (): ManagedPane | null => {
|
||||
const pane = managerRef.current?.getActivePane() ?? managerRef.current?.getPanes()[0] ?? null
|
||||
if (!pane) {
|
||||
return null
|
||||
}
|
||||
restoreInertFocusPasteTarget(pane, document.activeElement)
|
||||
return pane
|
||||
}
|
||||
|
||||
const onKeyPaste = (e: KeyboardEvent, recoveredFromInertFocus = false): void => {
|
||||
const target = e.target
|
||||
if (
|
||||
(target instanceof Element && target.closest('[data-terminal-search-root]')) ||
|
||||
@@ -2100,6 +2173,9 @@ function TerminalPane(
|
||||
}
|
||||
return
|
||||
}
|
||||
if (recoveredFromInertFocus && !restoreInertPaneFocus()) {
|
||||
return
|
||||
}
|
||||
if (isClipboardEventPasteRequired() && firesNativePasteEvent(e, isMac)) {
|
||||
// Why: without navigator.clipboard the chord's native paste event is the
|
||||
// only clipboard access — let its default fire and handle it in onPaste.
|
||||
@@ -2129,7 +2205,7 @@ function TerminalPane(
|
||||
}
|
||||
|
||||
// Fallback: paste events from non-keyboard sources (Edit > Paste menu, programmatic paste, etc.).
|
||||
const onPaste = (e: ClipboardEvent): void => {
|
||||
const onPaste = (e: ClipboardEvent, recoveredFromInertFocus = false): void => {
|
||||
const target = e.target
|
||||
if (
|
||||
(target instanceof Element && target.closest('[data-terminal-search-root]')) ||
|
||||
@@ -2149,6 +2225,9 @@ function TerminalPane(
|
||||
}
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (recoveredFromInertFocus && !restoreInertPaneFocus()) {
|
||||
return
|
||||
}
|
||||
const manager = managerRef.current
|
||||
if (!manager) {
|
||||
return
|
||||
@@ -2202,7 +2281,9 @@ function TerminalPane(
|
||||
executePanePasteText(pane, 'app-menu', activeElementAtDispatch, text, options),
|
||||
onTextPasteError: () =>
|
||||
setTerminalError('Paste failed: clipboard text is too large for a safe terminal paste.'),
|
||||
onImagePasteError: (error) => setTerminalError(formatClipboardImagePasteError(error))
|
||||
onImagePasteError: (error) => setTerminalError(formatClipboardImagePasteError(error)),
|
||||
onClipboardReadUnavailable: () =>
|
||||
setTerminalError(TERMINAL_CLIPBOARD_READ_UNAVAILABLE_MESSAGE)
|
||||
}).catch(() => {
|
||||
setTerminalError('Paste failed.')
|
||||
})
|
||||
@@ -2242,8 +2323,32 @@ function TerminalPane(
|
||||
}
|
||||
}
|
||||
|
||||
// A deferred payload lands the moment its own pane has focus again; focus
|
||||
// reaching a different pane is the wrong-target case the guard exists for.
|
||||
const onDeferredPasteFocusIn = createDeferredPasteFocusInHandler<ManagedPane>({
|
||||
queue: getDeferredPasteQueue(),
|
||||
getPanes: () => managerRef.current?.getPanes() ?? [],
|
||||
getFocusedElement: () => document.activeElement,
|
||||
deliver: (pane, deferred) =>
|
||||
void executePanePasteText(
|
||||
pane,
|
||||
deferred.source,
|
||||
document.activeElement,
|
||||
deferred.text,
|
||||
deferred.options,
|
||||
false
|
||||
),
|
||||
onDropped: () => setTerminalError(formatDeferredTerminalPasteDroppedError(shortcutPlatform))
|
||||
})
|
||||
|
||||
container.addEventListener('keydown', onKeyPaste, { capture: true })
|
||||
container.addEventListener('paste', onPaste, { capture: true })
|
||||
container.addEventListener('focusin', onDeferredPasteFocusIn)
|
||||
const inertFocusPasteFallback = installInertFocusPasteFallback({
|
||||
container,
|
||||
onPasteKey: (event) => onKeyPaste(event, true),
|
||||
onPasteEvent: (event) => onPaste(event, true)
|
||||
})
|
||||
window.addEventListener(APP_MENU_PASTE_EVENT, onAppMenuPaste)
|
||||
window.addEventListener(APP_MENU_SELECTION_ACTION_EVENT, onAppMenuSelectionAction)
|
||||
return () => {
|
||||
@@ -2252,6 +2357,8 @@ function TerminalPane(
|
||||
}
|
||||
container.removeEventListener('keydown', onKeyPaste, { capture: true })
|
||||
container.removeEventListener('paste', onPaste, { capture: true })
|
||||
container.removeEventListener('focusin', onDeferredPasteFocusIn)
|
||||
inertFocusPasteFallback.dispose()
|
||||
window.removeEventListener(APP_MENU_PASTE_EVENT, onAppMenuPaste)
|
||||
window.removeEventListener(APP_MENU_SELECTION_ACTION_EVENT, onAppMenuSelectionAction)
|
||||
}
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { CLIPBOARD_TEXT_TOO_LARGE_ERROR } from '../../../../shared/clipboard-text'
|
||||
import { pasteTerminalClipboard } from './terminal-clipboard-paste'
|
||||
|
||||
// STA-5272 defect 2: a clipboard text read that FAILS is `unavailable`, never `empty`.
|
||||
// Collapsing the two makes a failed read look to the user like "you copied nothing":
|
||||
// no paste, no error, no toast.
|
||||
describe('terminal clipboard paste: failed read is not an empty clipboard', () => {
|
||||
it('reports a failed text read as clipboard-read-failed, not empty', async () => {
|
||||
const readError = new Error('Read permission denied')
|
||||
const onClipboardReadUnavailable = vi.fn()
|
||||
const onTextPasteError = vi.fn()
|
||||
const pasteText = vi.fn()
|
||||
|
||||
const result = await pasteTerminalClipboard({
|
||||
readClipboardText: vi.fn().mockRejectedValue(readError),
|
||||
saveClipboardImageAsTempFile: vi.fn().mockResolvedValue(null),
|
||||
pasteText,
|
||||
onClipboardReadUnavailable,
|
||||
onTextPasteError
|
||||
})
|
||||
|
||||
expect(result).toEqual({ status: 'skipped', reason: 'clipboard-read-failed' })
|
||||
expect(onClipboardReadUnavailable).toHaveBeenCalledTimes(1)
|
||||
expect(onClipboardReadUnavailable).toHaveBeenCalledWith(readError)
|
||||
// The too-large path owns onTextPasteError; a plain read failure must not borrow it.
|
||||
expect(onTextPasteError).not.toHaveBeenCalled()
|
||||
expect(pasteText).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps a genuinely empty clipboard silent', async () => {
|
||||
const onClipboardReadUnavailable = vi.fn()
|
||||
const onTextPasteError = vi.fn()
|
||||
const onImagePasteError = vi.fn()
|
||||
|
||||
const result = await pasteTerminalClipboard({
|
||||
readClipboardText: vi.fn().mockResolvedValue(''),
|
||||
saveClipboardImageAsTempFile: vi.fn().mockResolvedValue(null),
|
||||
pasteText: vi.fn(),
|
||||
onClipboardReadUnavailable,
|
||||
onTextPasteError,
|
||||
onImagePasteError
|
||||
})
|
||||
|
||||
expect(result).toEqual({ status: 'skipped', reason: 'empty' })
|
||||
expect(onClipboardReadUnavailable).not.toHaveBeenCalled()
|
||||
expect(onTextPasteError).not.toHaveBeenCalled()
|
||||
expect(onImagePasteError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still pastes an image when the text read failed on an image-only clipboard', async () => {
|
||||
const onClipboardReadUnavailable = vi.fn()
|
||||
const pasteText = vi.fn()
|
||||
|
||||
const result = await pasteTerminalClipboard({
|
||||
readClipboardText: vi.fn().mockRejectedValue(new Error('No text on clipboard')),
|
||||
saveClipboardImageAsTempFile: vi.fn().mockResolvedValue('/tmp/orca-paste-1.png'),
|
||||
pasteText,
|
||||
onClipboardReadUnavailable
|
||||
})
|
||||
|
||||
expect(result).toEqual({ status: 'pasted', kind: 'image-path' })
|
||||
expect(pasteText).toHaveBeenCalledWith('/tmp/orca-paste-1.png', {
|
||||
forceBracketedPaste: true,
|
||||
recoverImagePasteWebglAtlas: true
|
||||
})
|
||||
expect(onClipboardReadUnavailable).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves the too-large read on its own error path', async () => {
|
||||
const tooLarge = new Error(CLIPBOARD_TEXT_TOO_LARGE_ERROR)
|
||||
const onClipboardReadUnavailable = vi.fn()
|
||||
const onTextPasteError = vi.fn()
|
||||
const saveClipboardImageAsTempFile = vi.fn()
|
||||
|
||||
const result = await pasteTerminalClipboard({
|
||||
readClipboardText: vi.fn().mockRejectedValue(tooLarge),
|
||||
saveClipboardImageAsTempFile,
|
||||
pasteText: vi.fn(),
|
||||
onClipboardReadUnavailable,
|
||||
onTextPasteError
|
||||
})
|
||||
|
||||
expect(result).toEqual({ status: 'skipped', reason: 'text-too-large' })
|
||||
expect(onTextPasteError).toHaveBeenCalledWith(tooLarge)
|
||||
expect(onClipboardReadUnavailable).not.toHaveBeenCalled()
|
||||
expect(saveClipboardImageAsTempFile).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -25,6 +25,7 @@ type PasteTerminalClipboardDeps = {
|
||||
protectedMultilineTextPasteOptions?: TerminalPasteTextOptions
|
||||
onTextPasteError?: (error: unknown) => void
|
||||
onImagePasteError?: (error: unknown) => void
|
||||
onClipboardReadUnavailable?: (error: unknown) => void
|
||||
}
|
||||
|
||||
export type TerminalClipboardPasteResult =
|
||||
@@ -33,6 +34,7 @@ export type TerminalClipboardPasteResult =
|
||||
status: 'skipped'
|
||||
reason:
|
||||
| 'empty'
|
||||
| 'clipboard-read-failed'
|
||||
| 'image-paste-failed'
|
||||
| 'image-paste-rejected'
|
||||
| 'text-paste-failed'
|
||||
@@ -49,9 +51,14 @@ export async function pasteTerminalClipboard({
|
||||
forceBracketedMultilineTextPaste = false,
|
||||
protectedMultilineTextPasteOptions,
|
||||
onTextPasteError,
|
||||
onImagePasteError
|
||||
onImagePasteError,
|
||||
onClipboardReadUnavailable
|
||||
}: PasteTerminalClipboardDeps): Promise<TerminalClipboardPasteResult> {
|
||||
let text = ''
|
||||
// Why: a failed read is `unavailable`, never `empty`. Holding the error keeps
|
||||
// the image path reachable for image-only clipboards without letting a real
|
||||
// read failure be reported to the user as "you copied nothing".
|
||||
let readFailure: { error: unknown } | null = null
|
||||
try {
|
||||
text = await readClipboardText({ maxBytes: TERMINAL_PASTE_MAX_BYTES })
|
||||
} catch (error) {
|
||||
@@ -61,6 +68,7 @@ export async function pasteTerminalClipboard({
|
||||
}
|
||||
// Why: browser clipboard text reads can fail for image-only clipboards.
|
||||
// Still try the image path so Cmd/Ctrl+V works for screenshots.
|
||||
readFailure = { error }
|
||||
}
|
||||
if (text) {
|
||||
try {
|
||||
@@ -81,6 +89,10 @@ export async function pasteTerminalClipboard({
|
||||
try {
|
||||
const filePath = await saveClipboardImageAsTempFile({ connectionId, runtimeEnvironmentId })
|
||||
if (!filePath) {
|
||||
if (readFailure) {
|
||||
onClipboardReadUnavailable?.(readFailure.error)
|
||||
return { status: 'skipped', reason: 'clipboard-read-failed' }
|
||||
}
|
||||
return { status: 'skipped', reason: 'empty' }
|
||||
}
|
||||
const result = await pasteText(filePath, {
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it, vi, type Mock } from 'vitest'
|
||||
|
||||
import {
|
||||
createDeferredPasteFocusInHandler,
|
||||
createDeferredTerminalPasteQueue,
|
||||
isDeferrablePasteFocusCancellation,
|
||||
isFocusInsideOtherPane,
|
||||
resolveDeferredPasteFocusIn,
|
||||
TERMINAL_DEFERRED_PASTE_TIMEOUT_MS,
|
||||
type DeferredTerminalPaste
|
||||
} from './terminal-deferred-paste'
|
||||
|
||||
type Timer = { id: number; callback: () => void; ms: number }
|
||||
|
||||
function createTimerHarness(): {
|
||||
setTimer: (callback: () => void, ms: number) => number
|
||||
clearTimer: (id: number) => void
|
||||
fire: (id?: number) => void
|
||||
live: () => Timer[]
|
||||
} {
|
||||
const timers = new Map<number, Timer>()
|
||||
let nextId = 1
|
||||
return {
|
||||
setTimer: (callback, ms) => {
|
||||
const id = nextId
|
||||
nextId += 1
|
||||
timers.set(id, { id, callback, ms })
|
||||
return id
|
||||
},
|
||||
clearTimer: (id) => {
|
||||
timers.delete(id)
|
||||
},
|
||||
fire: (id) => {
|
||||
const timer = id === undefined ? [...timers.values()][0] : timers.get(id)
|
||||
if (!timer) {
|
||||
throw new Error(`no live timer ${id ?? '(first)'}`)
|
||||
}
|
||||
timers.delete(timer.id)
|
||||
timer.callback()
|
||||
},
|
||||
live: () => [...timers.values()]
|
||||
}
|
||||
}
|
||||
|
||||
const entry = (overrides: Partial<DeferredTerminalPaste> = {}): DeferredTerminalPaste => ({
|
||||
paneId: 1,
|
||||
leafId: 'leaf-a',
|
||||
source: 'keyboard',
|
||||
text: 'dictated paragraph',
|
||||
...overrides
|
||||
})
|
||||
|
||||
describe('deferred terminal paste queue', () => {
|
||||
it('hands the payload back to the pane it was aimed at', () => {
|
||||
const timers = createTimerHarness()
|
||||
const onExpire = vi.fn()
|
||||
const queue = createDeferredTerminalPasteQueue({ onExpire, ...timers })
|
||||
|
||||
queue.defer(entry())
|
||||
|
||||
expect(queue.isPending()).toBe(true)
|
||||
expect(queue.claim(1, 'leaf-a')).toEqual(entry())
|
||||
expect(queue.isPending()).toBe(false)
|
||||
// The deadline must be disarmed by the claim, not left to fire later.
|
||||
expect(timers.live()).toEqual([])
|
||||
expect(onExpire).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses to hand the payload to a different pane or leaf', () => {
|
||||
const timers = createTimerHarness()
|
||||
const queue = createDeferredTerminalPasteQueue({ onExpire: vi.fn(), ...timers })
|
||||
|
||||
queue.defer(entry())
|
||||
|
||||
expect(queue.claim(2, 'leaf-a')).toBe(null)
|
||||
expect(queue.claim(1, 'leaf-b')).toBe(null)
|
||||
expect(queue.isPending()).toBe(true)
|
||||
})
|
||||
|
||||
it('drops the payload and signals the user when focus never comes back', () => {
|
||||
const timers = createTimerHarness()
|
||||
const onExpire = vi.fn()
|
||||
const queue = createDeferredTerminalPasteQueue({ onExpire, ...timers })
|
||||
|
||||
queue.defer(entry())
|
||||
expect(timers.live()[0]?.ms).toBe(TERMINAL_DEFERRED_PASTE_TIMEOUT_MS)
|
||||
|
||||
timers.fire()
|
||||
|
||||
expect(onExpire).toHaveBeenCalledTimes(1)
|
||||
expect(onExpire).toHaveBeenCalledWith(entry())
|
||||
// Bounded: the clipboard text is not retained past the deadline, and a late
|
||||
// focus return cannot make it land.
|
||||
expect(queue.isPending()).toBe(false)
|
||||
expect(queue.claim(1, 'leaf-a')).toBe(null)
|
||||
})
|
||||
|
||||
it('does not retain the payload when the expiry notifier throws', () => {
|
||||
const timers = createTimerHarness()
|
||||
const queue = createDeferredTerminalPasteQueue({
|
||||
onExpire: () => {
|
||||
throw new Error('toast surface gone')
|
||||
},
|
||||
...timers
|
||||
})
|
||||
|
||||
queue.defer(entry())
|
||||
expect(() => timers.fire()).toThrow('toast surface gone')
|
||||
|
||||
expect(queue.isPending()).toBe(false)
|
||||
expect(queue.claim(1, 'leaf-a')).toBe(null)
|
||||
})
|
||||
|
||||
it('lets a newer paste supersede the pending one without a stray expiry', () => {
|
||||
const timers = createTimerHarness()
|
||||
const onExpire = vi.fn()
|
||||
const queue = createDeferredTerminalPasteQueue({ onExpire, ...timers })
|
||||
|
||||
queue.defer(entry({ text: 'first' }))
|
||||
queue.defer(entry({ text: 'second' }))
|
||||
|
||||
expect(timers.live()).toHaveLength(1)
|
||||
timers.fire()
|
||||
expect(onExpire).toHaveBeenCalledTimes(1)
|
||||
expect(onExpire).toHaveBeenCalledWith(entry({ text: 'second' }))
|
||||
})
|
||||
|
||||
it('carries a payload larger than the 1024-byte typed-command cap intact', () => {
|
||||
const timers = createTimerHarness()
|
||||
const queue = createDeferredTerminalPasteQueue({ onExpire: vi.fn(), ...timers })
|
||||
// Line-bounded so the payload is never at the mercy of a TTY MAX_CANON limit.
|
||||
const large = Array.from({ length: 40 }, (_, index) => `line-${index}-${'x'.repeat(60)}`).join(
|
||||
'\n'
|
||||
)
|
||||
expect(new TextEncoder().encode(large).byteLength).toBeGreaterThan(1024)
|
||||
|
||||
queue.defer(entry({ text: large }))
|
||||
const claimed = queue.claim(1, 'leaf-a')
|
||||
|
||||
expect(claimed?.text).toBe(large)
|
||||
expect(new TextEncoder().encode(claimed?.text ?? '').byteLength).toBe(
|
||||
new TextEncoder().encode(large).byteLength
|
||||
)
|
||||
})
|
||||
|
||||
it('drops the payload on dispose without firing the expiry signal', () => {
|
||||
const timers = createTimerHarness()
|
||||
const onExpire = vi.fn()
|
||||
const queue = createDeferredTerminalPasteQueue({ onExpire, ...timers })
|
||||
|
||||
queue.defer(entry())
|
||||
queue.dispose()
|
||||
|
||||
expect(queue.isPending()).toBe(false)
|
||||
expect(timers.live()).toEqual([])
|
||||
expect(onExpire).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isDeferrablePasteFocusCancellation', () => {
|
||||
const base = {
|
||||
status: 'cancelled' as const,
|
||||
reason: 'stale-target' as const,
|
||||
targetMounted: true,
|
||||
focusMovedToOtherPane: false
|
||||
}
|
||||
|
||||
it('defers only a focus-loss cancellation whose pane is still the live target', () => {
|
||||
expect(isDeferrablePasteFocusCancellation(base)).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses to defer when focus moved to a different pane', () => {
|
||||
// The wrong-target case the guard actually exists for.
|
||||
expect(isDeferrablePasteFocusCancellation({ ...base, focusMovedToOtherPane: true })).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses to defer when the target is gone', () => {
|
||||
expect(isDeferrablePasteFocusCancellation({ ...base, targetMounted: false })).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses to defer non-focus cancellations and rejections', () => {
|
||||
expect(isDeferrablePasteFocusCancellation({ ...base, reason: 'target-disconnected' })).toBe(
|
||||
false
|
||||
)
|
||||
expect(isDeferrablePasteFocusCancellation({ ...base, reason: 'operation-timeout' })).toBe(false)
|
||||
expect(isDeferrablePasteFocusCancellation({ ...base, reason: undefined })).toBe(false)
|
||||
expect(
|
||||
isDeferrablePasteFocusCancellation({
|
||||
...base,
|
||||
status: 'rejected',
|
||||
reason: 'payload-too-large'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deferred paste focus resolution', () => {
|
||||
const buildPanes = (): {
|
||||
panes: { id: number; leafId: string; container: HTMLElement }[]
|
||||
focusable: (paneIndex: number) => HTMLElement
|
||||
} => {
|
||||
const root = document.createElement('div')
|
||||
document.body.append(root)
|
||||
const panes = [0, 1].map((index) => {
|
||||
const container = document.createElement('div')
|
||||
const helper = document.createElement('textarea')
|
||||
helper.className = 'xterm-helper-textarea'
|
||||
container.append(helper)
|
||||
root.append(container)
|
||||
return { id: index + 1, leafId: `leaf-${index}`, container }
|
||||
})
|
||||
return {
|
||||
panes,
|
||||
focusable: (paneIndex) => panes[paneIndex]!.container.firstElementChild as HTMLElement
|
||||
}
|
||||
}
|
||||
|
||||
it('delivers the payload when focus returns to its own pane', () => {
|
||||
const { panes, focusable } = buildPanes()
|
||||
const queue = createDeferredTerminalPasteQueue({ onExpire: vi.fn(), ...createTimerHarness() })
|
||||
queue.defer(entry({ paneId: 1, leafId: 'leaf-0' }))
|
||||
|
||||
const resolution = resolveDeferredPasteFocusIn({
|
||||
panes,
|
||||
focusedElement: focusable(0),
|
||||
queue
|
||||
})
|
||||
|
||||
expect(resolution.action).toBe('deliver')
|
||||
expect(resolution.action === 'deliver' && resolution.entry.text).toBe('dictated paragraph')
|
||||
expect(queue.isPending()).toBe(false)
|
||||
})
|
||||
|
||||
it('drops the payload when focus lands in a different pane', () => {
|
||||
const { panes, focusable } = buildPanes()
|
||||
const queue = createDeferredTerminalPasteQueue({ onExpire: vi.fn(), ...createTimerHarness() })
|
||||
queue.defer(entry({ paneId: 1, leafId: 'leaf-0' }))
|
||||
|
||||
const resolution = resolveDeferredPasteFocusIn({
|
||||
panes,
|
||||
focusedElement: focusable(1),
|
||||
queue
|
||||
})
|
||||
|
||||
expect(resolution.action).toBe('drop')
|
||||
expect(resolution.action === 'drop' && resolution.entry?.text).toBe('dictated paragraph')
|
||||
expect(queue.isPending()).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores focus that lands outside every pane', () => {
|
||||
const { panes } = buildPanes()
|
||||
const outside = document.createElement('input')
|
||||
document.body.append(outside)
|
||||
const queue = createDeferredTerminalPasteQueue({ onExpire: vi.fn(), ...createTimerHarness() })
|
||||
queue.defer(entry({ paneId: 1, leafId: 'leaf-0' }))
|
||||
|
||||
expect(resolveDeferredPasteFocusIn({ panes, focusedElement: outside, queue }).action).toBe(
|
||||
'ignore'
|
||||
)
|
||||
expect(queue.isPending()).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores focus movement when nothing is deferred', () => {
|
||||
const { panes, focusable } = buildPanes()
|
||||
const queue = createDeferredTerminalPasteQueue({ onExpire: vi.fn(), ...createTimerHarness() })
|
||||
|
||||
expect(resolveDeferredPasteFocusIn({ panes, focusedElement: focusable(0), queue }).action).toBe(
|
||||
'ignore'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isFocusInsideOtherPane', () => {
|
||||
it('separates the paste target from its split siblings', () => {
|
||||
const root = document.createElement('div')
|
||||
document.body.append(root)
|
||||
const own = document.createElement('div')
|
||||
const sibling = document.createElement('div')
|
||||
const ownChild = document.createElement('textarea')
|
||||
const siblingChild = document.createElement('textarea')
|
||||
own.append(ownChild)
|
||||
sibling.append(siblingChild)
|
||||
root.append(own, sibling)
|
||||
const panes = [
|
||||
{ id: 1, leafId: 'leaf-0', container: own },
|
||||
{ id: 2, leafId: 'leaf-1', container: sibling }
|
||||
]
|
||||
|
||||
expect(isFocusInsideOtherPane({ panes, paneId: 1, focusedElement: ownChild })).toBe(false)
|
||||
expect(isFocusInsideOtherPane({ panes, paneId: 1, focusedElement: siblingChild })).toBe(true)
|
||||
expect(isFocusInsideOtherPane({ panes, paneId: 1, focusedElement: document.body })).toBe(false)
|
||||
expect(isFocusInsideOtherPane({ panes, paneId: 1, focusedElement: null })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// The pane's real focusin handler, driven by real DOM focus moves.
|
||||
describe('deferred paste focusin handler', () => {
|
||||
const buildScene = (): {
|
||||
handler: () => void
|
||||
queue: ReturnType<typeof createDeferredTerminalPasteQueue>
|
||||
deliver: Mock<(pane: { id: number }, deferred: DeferredTerminalPaste) => void>
|
||||
onDropped: Mock<(deferred: DeferredTerminalPaste) => void>
|
||||
onExpire: Mock<(deferred: DeferredTerminalPaste) => void>
|
||||
timers: ReturnType<typeof createTimerHarness>
|
||||
focusables: HTMLElement[]
|
||||
root: HTMLElement
|
||||
} => {
|
||||
document.body.innerHTML = ''
|
||||
const root = document.createElement('div')
|
||||
document.body.append(root)
|
||||
const panes = [0, 1].map((index) => {
|
||||
const container = document.createElement('div')
|
||||
const helper = document.createElement('textarea')
|
||||
helper.className = 'xterm-helper-textarea'
|
||||
container.append(helper)
|
||||
root.append(container)
|
||||
return { id: index + 1, leafId: `leaf-${index}`, container }
|
||||
})
|
||||
const timers = createTimerHarness()
|
||||
const onExpire = vi.fn<(deferred: DeferredTerminalPaste) => void>()
|
||||
const queue = createDeferredTerminalPasteQueue({ onExpire, ...timers })
|
||||
const deliver = vi.fn<(pane: { id: number }, deferred: DeferredTerminalPaste) => void>()
|
||||
const onDropped = vi.fn<(deferred: DeferredTerminalPaste) => void>()
|
||||
const handler = createDeferredPasteFocusInHandler({
|
||||
queue,
|
||||
getPanes: () => panes,
|
||||
getFocusedElement: () => document.activeElement,
|
||||
deliver,
|
||||
onDropped
|
||||
})
|
||||
root.addEventListener('focusin', handler)
|
||||
return {
|
||||
handler,
|
||||
queue,
|
||||
deliver,
|
||||
onDropped,
|
||||
onExpire,
|
||||
timers,
|
||||
focusables: panes.map((pane) => pane.container.firstElementChild as HTMLElement),
|
||||
root
|
||||
}
|
||||
}
|
||||
|
||||
it('lands the deferred payload when focus returns to the pane', () => {
|
||||
const scene = buildScene()
|
||||
scene.queue.defer(entry({ paneId: 1, leafId: 'leaf-0', text: 'a dictated paragraph' }))
|
||||
|
||||
scene.focusables[0]!.focus()
|
||||
|
||||
expect(scene.deliver).toHaveBeenCalledTimes(1)
|
||||
expect(scene.deliver.mock.calls[0]?.[0]?.id).toBe(1)
|
||||
expect(scene.deliver.mock.calls[0]?.[1]?.text).toBe('a dictated paragraph')
|
||||
expect(scene.onDropped).not.toHaveBeenCalled()
|
||||
expect(scene.queue.isPending()).toBe(false)
|
||||
expect(scene.timers.live()).toEqual([])
|
||||
})
|
||||
|
||||
it('lands a payload larger than the 1024-byte typed-command cap without truncating it', () => {
|
||||
const scene = buildScene()
|
||||
const large = Array.from({ length: 40 }, (_, index) => `line-${index}-${'x'.repeat(60)}`).join(
|
||||
'\n'
|
||||
)
|
||||
expect(new TextEncoder().encode(large).byteLength).toBeGreaterThan(1024)
|
||||
scene.queue.defer(entry({ paneId: 1, leafId: 'leaf-0', text: large }))
|
||||
|
||||
scene.focusables[0]!.focus()
|
||||
|
||||
expect(scene.deliver.mock.calls[0]?.[1]?.text).toBe(large)
|
||||
})
|
||||
|
||||
it('drops the payload with a signal when a different pane takes focus', () => {
|
||||
const scene = buildScene()
|
||||
scene.queue.defer(entry({ paneId: 1, leafId: 'leaf-0' }))
|
||||
|
||||
scene.focusables[1]!.focus()
|
||||
|
||||
expect(scene.deliver).not.toHaveBeenCalled()
|
||||
expect(scene.onDropped).toHaveBeenCalledTimes(1)
|
||||
expect(scene.queue.isPending()).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the payload pending while focus has not come back yet, then expires it', () => {
|
||||
const scene = buildScene()
|
||||
scene.queue.defer(entry({ paneId: 1, leafId: 'leaf-0' }))
|
||||
const outside = document.createElement('input')
|
||||
document.body.append(outside)
|
||||
|
||||
outside.focus()
|
||||
|
||||
expect(scene.deliver).not.toHaveBeenCalled()
|
||||
expect(scene.onDropped).not.toHaveBeenCalled()
|
||||
expect(scene.queue.isPending()).toBe(true)
|
||||
|
||||
scene.timers.fire()
|
||||
|
||||
expect(scene.onExpire).toHaveBeenCalledTimes(1)
|
||||
// Bounded: focus coming back after the deadline must not fire a surprise paste.
|
||||
scene.focusables[0]!.focus()
|
||||
expect(scene.deliver).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing on ordinary focus moves when no paste is deferred', () => {
|
||||
const scene = buildScene()
|
||||
|
||||
scene.focusables[0]!.focus()
|
||||
scene.focusables[1]!.focus()
|
||||
|
||||
expect(scene.deliver).not.toHaveBeenCalled()
|
||||
expect(scene.onDropped).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,187 @@
|
||||
import type {
|
||||
TerminalPasteExecutionReason,
|
||||
TerminalPasteExecutionResult,
|
||||
TerminalPasteSource,
|
||||
TerminalPasteTextOptions
|
||||
} from './terminal-paste-model'
|
||||
|
||||
/** Long enough for a dictation/IME/clipboard-manager overlay to hand focus back,
|
||||
* short enough that the payload can never land in a surprising place later. */
|
||||
export const TERMINAL_DEFERRED_PASTE_TIMEOUT_MS = 2_000
|
||||
|
||||
export type DeferredTerminalPaste = {
|
||||
paneId: number
|
||||
leafId: string
|
||||
source: TerminalPasteSource
|
||||
text: string
|
||||
options?: TerminalPasteTextOptions
|
||||
}
|
||||
|
||||
export type DeferredTerminalPasteQueue = {
|
||||
/** Holds one payload; a second defer replaces the first and restarts the deadline. */
|
||||
defer: (entry: DeferredTerminalPaste) => void
|
||||
/** Returns and clears the payload only when it belongs to this pane. */
|
||||
claim: (paneId: number, leafId: string) => DeferredTerminalPaste | null
|
||||
/** Drops the payload without firing the expiry callback; returns what was dropped. */
|
||||
cancel: () => DeferredTerminalPaste | null
|
||||
isPending: () => boolean
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
type CreateDeferredTerminalPasteQueueArgs = {
|
||||
onExpire: (entry: DeferredTerminalPaste) => void
|
||||
timeoutMs?: number
|
||||
setTimer?: (callback: () => void, ms: number) => number
|
||||
clearTimer?: (timerId: number) => void
|
||||
}
|
||||
|
||||
export function createDeferredTerminalPasteQueue({
|
||||
onExpire,
|
||||
timeoutMs = TERMINAL_DEFERRED_PASTE_TIMEOUT_MS,
|
||||
setTimer = (callback, ms) => window.setTimeout(callback, ms),
|
||||
clearTimer = (timerId) => window.clearTimeout(timerId)
|
||||
}: CreateDeferredTerminalPasteQueueArgs): DeferredTerminalPasteQueue {
|
||||
let pending: DeferredTerminalPaste | null = null
|
||||
let timerId: number | null = null
|
||||
|
||||
const take = (): DeferredTerminalPaste | null => {
|
||||
const taken = pending
|
||||
pending = null
|
||||
if (timerId !== null) {
|
||||
clearTimer(timerId)
|
||||
timerId = null
|
||||
}
|
||||
return taken
|
||||
}
|
||||
|
||||
return {
|
||||
defer: (entry) => {
|
||||
take()
|
||||
pending = entry
|
||||
timerId = setTimer(() => {
|
||||
timerId = null
|
||||
// Why: release the clipboard text before notifying, so a throwing
|
||||
// notifier cannot leave the payload retained past its deadline.
|
||||
const expired = pending
|
||||
pending = null
|
||||
if (expired) {
|
||||
onExpire(expired)
|
||||
}
|
||||
}, timeoutMs)
|
||||
},
|
||||
claim: (paneId, leafId) => {
|
||||
if (!pending || pending.paneId !== paneId || pending.leafId !== leafId) {
|
||||
return null
|
||||
}
|
||||
return take()
|
||||
},
|
||||
cancel: () => take(),
|
||||
isPending: () => pending !== null,
|
||||
dispose: () => {
|
||||
take()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A paste the focus guard stopped is only deferrable while its pane is still the
|
||||
* live target and no other pane has taken focus — the case the guard exists for. */
|
||||
export function isDeferrablePasteFocusCancellation({
|
||||
status,
|
||||
reason,
|
||||
targetMounted,
|
||||
focusMovedToOtherPane
|
||||
}: {
|
||||
status: TerminalPasteExecutionResult['status']
|
||||
reason: TerminalPasteExecutionReason | undefined
|
||||
targetMounted: boolean
|
||||
focusMovedToOtherPane: boolean
|
||||
}): boolean {
|
||||
return (
|
||||
status === 'cancelled' && reason === 'stale-target' && targetMounted && !focusMovedToOtherPane
|
||||
)
|
||||
}
|
||||
|
||||
type DeferredPasteFocusPane = {
|
||||
id: number
|
||||
leafId: string
|
||||
container: { contains: (node: Node | null) => boolean }
|
||||
}
|
||||
|
||||
export type DeferredPasteFocusResolution<TPane extends DeferredPasteFocusPane> =
|
||||
| { action: 'ignore' }
|
||||
| { action: 'deliver'; pane: TPane; entry: DeferredTerminalPaste }
|
||||
| { action: 'drop'; pane: TPane; entry: DeferredTerminalPaste | null }
|
||||
|
||||
/** Focus landing back inside the deferred pane delivers it; focus landing in a
|
||||
* different pane drops it, because that is the wrong-target case the guard protects. */
|
||||
export function resolveDeferredPasteFocusIn<TPane extends DeferredPasteFocusPane>({
|
||||
panes,
|
||||
focusedElement,
|
||||
queue
|
||||
}: {
|
||||
panes: readonly TPane[]
|
||||
focusedElement: Node | null
|
||||
queue: DeferredTerminalPasteQueue
|
||||
}): DeferredPasteFocusResolution<TPane> {
|
||||
if (!queue.isPending() || !focusedElement) {
|
||||
return { action: 'ignore' }
|
||||
}
|
||||
const pane = panes.find((candidate) => candidate.container.contains(focusedElement))
|
||||
if (!pane) {
|
||||
return { action: 'ignore' }
|
||||
}
|
||||
const entry = queue.claim(pane.id, pane.leafId)
|
||||
if (entry) {
|
||||
return { action: 'deliver', pane, entry }
|
||||
}
|
||||
return { action: 'drop', pane, entry: queue.cancel() }
|
||||
}
|
||||
|
||||
/** True when focus currently sits in a pane other than the paste's own target. */
|
||||
export function isFocusInsideOtherPane<TPane extends DeferredPasteFocusPane>({
|
||||
panes,
|
||||
paneId,
|
||||
focusedElement
|
||||
}: {
|
||||
panes: readonly TPane[]
|
||||
paneId: number
|
||||
focusedElement: Node | null
|
||||
}): boolean {
|
||||
if (!focusedElement) {
|
||||
return false
|
||||
}
|
||||
return panes.some(
|
||||
(candidate) => candidate.id !== paneId && candidate.container.contains(focusedElement)
|
||||
)
|
||||
}
|
||||
|
||||
/** The pane's focusin handler: deliver the payload to its own pane, drop it when a
|
||||
* different pane takes focus, and leave it pending for anything else. */
|
||||
export function createDeferredPasteFocusInHandler<TPane extends DeferredPasteFocusPane>({
|
||||
queue,
|
||||
getPanes,
|
||||
getFocusedElement,
|
||||
deliver,
|
||||
onDropped
|
||||
}: {
|
||||
queue: DeferredTerminalPasteQueue
|
||||
getPanes: () => readonly TPane[]
|
||||
getFocusedElement: () => Node | null
|
||||
deliver: (pane: TPane, entry: DeferredTerminalPaste) => void
|
||||
onDropped: (entry: DeferredTerminalPaste) => void
|
||||
}): () => void {
|
||||
return () => {
|
||||
const resolution = resolveDeferredPasteFocusIn({
|
||||
panes: getPanes(),
|
||||
focusedElement: getFocusedElement(),
|
||||
queue
|
||||
})
|
||||
if (resolution.action === 'deliver') {
|
||||
deliver(resolution.pane, resolution.entry)
|
||||
return
|
||||
}
|
||||
if (resolution.action === 'drop' && resolution.entry) {
|
||||
onDropped(resolution.entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
|
||||
import {
|
||||
installInertFocusPasteFallback,
|
||||
restoreInertFocusPasteTarget
|
||||
} from './terminal-inert-focus-paste-fallback'
|
||||
|
||||
// STA-5272 / STA-3834: a dictation tool, IME, or overlay hands focus back to <body>,
|
||||
// not to the pane. The pane's own keydown/paste listeners are bound to its root
|
||||
// container with capture, so a body-targeted chord never reaches them: no paste,
|
||||
// no error, nothing. Everything here runs on real DOM nodes and real dispatched
|
||||
// events — a `contains` double would let this pass against unreachable state.
|
||||
describe('inert-focus paste fallback', () => {
|
||||
let container: HTMLElement
|
||||
let inside: HTMLTextAreaElement
|
||||
let outside: HTMLInputElement
|
||||
let onPasteKey: Mock<(event: KeyboardEvent) => void>
|
||||
let onPasteEvent: Mock<(event: ClipboardEvent) => void>
|
||||
let dispose: (() => void) | null
|
||||
|
||||
const install = (): ReturnType<typeof installInertFocusPasteFallback> => {
|
||||
const fallback = installInertFocusPasteFallback({ container, onPasteKey, onPasteEvent })
|
||||
dispose = fallback.dispose
|
||||
return fallback
|
||||
}
|
||||
const pressPasteChordOn = (target: EventTarget): void => {
|
||||
target.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'v', ctrlKey: true, bubbles: true, cancelable: true })
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
container = document.createElement('div')
|
||||
inside = document.createElement('textarea')
|
||||
inside.className = 'xterm-helper-textarea'
|
||||
container.append(inside)
|
||||
outside = document.createElement('input')
|
||||
document.body.append(container, outside)
|
||||
onPasteKey = vi.fn()
|
||||
onPasteEvent = vi.fn()
|
||||
dispose = null
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
dispose?.()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('premise: a container-bound capture listener never sees a body-targeted chord', () => {
|
||||
const paneListener = vi.fn()
|
||||
container.addEventListener('keydown', paneListener, { capture: true })
|
||||
inside.focus()
|
||||
inside.blur()
|
||||
|
||||
pressPasteChordOn(document.body)
|
||||
|
||||
// This is the whole defect: body is the container's ancestor, so the capture
|
||||
// path document -> html -> body never reaches the container.
|
||||
expect(paneListener).not.toHaveBeenCalled()
|
||||
container.removeEventListener('keydown', paneListener, { capture: true })
|
||||
})
|
||||
|
||||
it('recovers a paste chord that lands on body after the pane blurred to it', () => {
|
||||
install()
|
||||
inside.focus()
|
||||
inside.blur()
|
||||
|
||||
expect(document.activeElement).toBe(document.body)
|
||||
pressPasteChordOn(document.body)
|
||||
|
||||
expect(onPasteKey).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('recovers a native paste event that lands on body the same way', () => {
|
||||
install()
|
||||
inside.focus()
|
||||
inside.blur()
|
||||
|
||||
document.body.dispatchEvent(new Event('paste', { bubbles: true, cancelable: true }))
|
||||
|
||||
expect(onPasteEvent).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('leaves events inside the pane to the pane so a paste cannot fire twice', () => {
|
||||
install()
|
||||
inside.focus()
|
||||
|
||||
pressPasteChordOn(inside)
|
||||
|
||||
expect(onPasteKey).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves an in-pane event to the pane even while focus itself is inert', () => {
|
||||
install()
|
||||
inside.focus()
|
||||
inside.blur()
|
||||
expect(document.activeElement).toBe(document.body)
|
||||
|
||||
// A programmatic paste aimed at a pane child while focus sits on body: the
|
||||
// pane's own capture listener does receive this one, so the fallback must not
|
||||
// handle it as well and paste twice.
|
||||
inside.dispatchEvent(new Event('paste', { bubbles: true, cancelable: true }))
|
||||
pressPasteChordOn(inside)
|
||||
|
||||
expect(onPasteEvent).not.toHaveBeenCalled()
|
||||
expect(onPasteKey).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not steal a paste once another surface has taken focus', () => {
|
||||
install()
|
||||
inside.focus()
|
||||
outside.focus()
|
||||
// Focus then falls to body from that other surface, not from the pane.
|
||||
outside.blur()
|
||||
|
||||
expect(document.activeElement).toBe(document.body)
|
||||
pressPasteChordOn(document.body)
|
||||
|
||||
expect(onPasteKey).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not steal a paste after the user clicks outside the pane', () => {
|
||||
install()
|
||||
inside.focus()
|
||||
inside.blur()
|
||||
// A click on a non-focusable region blurs to body with no focusin at all, so
|
||||
// the pointer is the only evidence the user moved on.
|
||||
document.body.dispatchEvent(new Event('pointerdown', { bubbles: true }))
|
||||
|
||||
pressPasteChordOn(document.body)
|
||||
|
||||
expect(onPasteKey).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps ownership through a click inside the pane', () => {
|
||||
install()
|
||||
inside.focus()
|
||||
inside.dispatchEvent(new Event('pointerdown', { bubbles: true }))
|
||||
inside.blur()
|
||||
|
||||
pressPasteChordOn(document.body)
|
||||
|
||||
expect(onPasteKey).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not fire while a real element outside the pane holds focus', () => {
|
||||
install()
|
||||
inside.focus()
|
||||
outside.focus()
|
||||
|
||||
expect(document.activeElement).toBe(outside)
|
||||
pressPasteChordOn(outside)
|
||||
|
||||
expect(onPasteKey).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('claims focus that already sat in the pane when the listeners were installed', () => {
|
||||
inside.focus()
|
||||
install()
|
||||
inside.blur()
|
||||
|
||||
pressPasteChordOn(document.body)
|
||||
|
||||
expect(onPasteKey).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reports inert-focus ownership so other paste entry points can share the test', () => {
|
||||
const fallback = install()
|
||||
|
||||
expect(fallback.ownsInertFocus()).toBe(false)
|
||||
inside.focus()
|
||||
expect(fallback.ownsInertFocus()).toBe(false)
|
||||
inside.blur()
|
||||
expect(fallback.ownsInertFocus()).toBe(true)
|
||||
outside.focus()
|
||||
expect(fallback.ownsInertFocus()).toBe(false)
|
||||
})
|
||||
|
||||
it('stops recovering pastes after dispose', () => {
|
||||
const fallback = install()
|
||||
inside.focus()
|
||||
inside.blur()
|
||||
fallback.dispose()
|
||||
dispose = null
|
||||
|
||||
pressPasteChordOn(document.body)
|
||||
document.body.dispatchEvent(new Event('paste', { bubbles: true, cancelable: true }))
|
||||
|
||||
expect(onPasteKey).not.toHaveBeenCalled()
|
||||
expect(onPasteEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('restoreInertFocusPasteTarget', () => {
|
||||
const buildPane = (): { container: HTMLElement; terminal: { focus: Mock<() => void> } } => {
|
||||
document.body.innerHTML = ''
|
||||
const container = document.createElement('div')
|
||||
const helper = document.createElement('textarea')
|
||||
helper.className = 'xterm-helper-textarea'
|
||||
container.append(helper)
|
||||
document.body.append(container)
|
||||
return { container, terminal: { focus: vi.fn<() => void>() } }
|
||||
}
|
||||
|
||||
it('puts focus back on the pane when focus fell to body', () => {
|
||||
const pane = buildPane()
|
||||
|
||||
restoreInertFocusPasteTarget(pane, document.body)
|
||||
|
||||
expect(pane.terminal.focus).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('puts focus back on the pane when nothing at all has focus', () => {
|
||||
const pane = buildPane()
|
||||
|
||||
restoreInertFocusPasteTarget(pane, null)
|
||||
|
||||
expect(pane.terminal.focus).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('leaves focus alone when it is already inside the pane', () => {
|
||||
const pane = buildPane()
|
||||
|
||||
restoreInertFocusPasteTarget(pane, pane.container.firstElementChild as Element)
|
||||
|
||||
expect(pane.terminal.focus).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import { isInertDocumentFocus } from './terminal-paste-target-state'
|
||||
|
||||
type InstallInertFocusPasteFallbackArgs = {
|
||||
/** TerminalPane root; the pane's own listeners already cover events inside it. */
|
||||
container: HTMLElement
|
||||
documentTarget?: Document
|
||||
onPasteKey: (event: KeyboardEvent) => void
|
||||
onPasteEvent: (event: ClipboardEvent) => void
|
||||
}
|
||||
|
||||
export type InertFocusPasteFallback = {
|
||||
/** True while this pane is the last surface to have held focus and focus has
|
||||
* since fallen to `<body>` rather than moving to another surface. */
|
||||
ownsInertFocus: () => boolean
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
/** A dictation tool, IME, or overlay hands focus back to `<body>`, not to the pane.
|
||||
* The pane's own capture listeners never see those keydown/paste events, so the
|
||||
* chord is dropped with no paste and no error. Recover it at the document, but
|
||||
* only while nothing else has claimed focus or the pointer. */
|
||||
export function installInertFocusPasteFallback({
|
||||
container,
|
||||
documentTarget = document,
|
||||
onPasteKey,
|
||||
onPasteEvent
|
||||
}: InstallInertFocusPasteFallbackArgs): InertFocusPasteFallback {
|
||||
let paneOwnsFocus = containsNode(container, documentTarget.activeElement)
|
||||
|
||||
const onFocusIn = (event: FocusEvent): void => {
|
||||
paneOwnsFocus = containsNode(container, event.target)
|
||||
}
|
||||
// Why: a click on a non-focusable region blurs to <body> without any focusin,
|
||||
// so the pointer is the only signal that the user moved on.
|
||||
const onPointerDown = (event: Event): void => {
|
||||
if (!containsNode(container, event.target)) {
|
||||
paneOwnsFocus = false
|
||||
}
|
||||
}
|
||||
const ownsInertFocus = (): boolean =>
|
||||
paneOwnsFocus && isInertDocumentFocus(documentTarget.activeElement)
|
||||
const shouldRecover = (event: Event): boolean =>
|
||||
!containsNode(container, event.target) && ownsInertFocus()
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent): void => {
|
||||
if (shouldRecover(event)) {
|
||||
onPasteKey(event)
|
||||
}
|
||||
}
|
||||
const onPaste = (event: ClipboardEvent): void => {
|
||||
if (shouldRecover(event)) {
|
||||
onPasteEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
documentTarget.addEventListener('focusin', onFocusIn, { capture: true })
|
||||
documentTarget.addEventListener('pointerdown', onPointerDown, { capture: true })
|
||||
documentTarget.addEventListener('keydown', onKeyDown, { capture: true })
|
||||
documentTarget.addEventListener('paste', onPaste, { capture: true })
|
||||
|
||||
return {
|
||||
ownsInertFocus,
|
||||
dispose: () => {
|
||||
documentTarget.removeEventListener('focusin', onFocusIn, { capture: true })
|
||||
documentTarget.removeEventListener('pointerdown', onPointerDown, { capture: true })
|
||||
documentTarget.removeEventListener('keydown', onKeyDown, { capture: true })
|
||||
documentTarget.removeEventListener('paste', onPaste, { capture: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function containsNode(container: HTMLElement, node: EventTarget | Node | null): boolean {
|
||||
return node instanceof Node && container.contains(node)
|
||||
}
|
||||
|
||||
/** Focus is on <body> but the pane still owns it logically; putting it back before
|
||||
* the shared paste path runs lets that path's dispatch-element guard see the real target. */
|
||||
export function restoreInertFocusPasteTarget(
|
||||
pane: { container: HTMLElement; terminal: { focus: () => void } },
|
||||
activeElement: Element | null
|
||||
): void {
|
||||
if (!activeElement || !pane.container.contains(activeElement)) {
|
||||
pane.terminal.focus()
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,10 @@ import {
|
||||
type TerminalPasteSource,
|
||||
type TerminalPasteTextOptions
|
||||
} from './terminal-paste-coordinator'
|
||||
import { formatTerminalPasteExecutionError } from './terminal-paste-errors'
|
||||
import {
|
||||
formatTerminalPasteExecutionError,
|
||||
TERMINAL_CLIPBOARD_READ_UNAVAILABLE_MESSAGE
|
||||
} from './terminal-paste-errors'
|
||||
import { resolveTerminalPasteRuntime } from './terminal-paste-runtime'
|
||||
import { getTerminalPasteSshRemotePlatform } from './terminal-paste-ssh-platform'
|
||||
import { isTerminalPanePasteTargetCurrent } from './terminal-paste-target-state'
|
||||
@@ -144,7 +147,8 @@ export const pasteTerminalPaneMenuClipboard = async (
|
||||
onImagePasteError: (error) => {
|
||||
const detail = error instanceof Error ? error.message : String(error)
|
||||
onPasteError(`Image paste failed: ${detail}`)
|
||||
}
|
||||
},
|
||||
onClipboardReadUnavailable: () => onPasteError(TERMINAL_CLIPBOARD_READ_UNAVAILABLE_MESSAGE)
|
||||
})
|
||||
if (result.status !== 'pasted') {
|
||||
return
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
formatDeferredTerminalPasteDroppedError,
|
||||
formatTerminalPasteExecutionError,
|
||||
TERMINAL_CLIPBOARD_READ_UNAVAILABLE_MESSAGE
|
||||
} from './terminal-paste-errors'
|
||||
|
||||
describe('terminal paste error copy', () => {
|
||||
it('tells the user how to recover a deferred paste that timed out, per platform', () => {
|
||||
// The payload is already gone, so the copy has to be actionable, and the chord
|
||||
// label has to match the platform the user is actually on (reported on Win 11).
|
||||
expect(formatDeferredTerminalPasteDroppedError('darwin')).toBe(
|
||||
'Paste cancelled: terminal focus did not return in time. Click the terminal and press ⌘V to paste again.'
|
||||
)
|
||||
expect(formatDeferredTerminalPasteDroppedError('win32')).toBe(
|
||||
'Paste cancelled: terminal focus did not return in time. Click the terminal and press Ctrl+V to paste again.'
|
||||
)
|
||||
expect(formatDeferredTerminalPasteDroppedError('linux')).toBe(
|
||||
'Paste cancelled: terminal focus did not return in time. Click the terminal and press Ctrl+V to paste again.'
|
||||
)
|
||||
})
|
||||
|
||||
it('names a failed clipboard read as a failure, never as an empty clipboard', () => {
|
||||
expect(TERMINAL_CLIPBOARD_READ_UNAVAILABLE_MESSAGE).toBe(
|
||||
'Paste failed: could not read the clipboard. Copy again, then retry.'
|
||||
)
|
||||
// Distinct from every execution-stage message, so the toast cannot be mistaken
|
||||
// for a focus or transport problem.
|
||||
const executionMessages = (
|
||||
[
|
||||
'payload-too-large',
|
||||
'stale-target',
|
||||
'target-disconnected',
|
||||
'pty-writer-unavailable',
|
||||
'operation-timeout',
|
||||
undefined
|
||||
] as const
|
||||
).map((reason) => formatTerminalPasteExecutionError(reason))
|
||||
expect(executionMessages).not.toContain(TERMINAL_CLIPBOARD_READ_UNAVAILABLE_MESSAGE)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,9 @@
|
||||
import { getShortcutPlatform } from '@/lib/shortcut-platform'
|
||||
import type { TerminalPasteExecutionReason } from './terminal-paste-model'
|
||||
|
||||
export const TERMINAL_CLIPBOARD_READ_UNAVAILABLE_MESSAGE =
|
||||
'Paste failed: could not read the clipboard. Copy again, then retry.'
|
||||
|
||||
export function formatTerminalPasteExecutionError(
|
||||
reason: TerminalPasteExecutionReason | undefined
|
||||
): string {
|
||||
@@ -20,3 +24,12 @@ export function formatTerminalPasteExecutionError(
|
||||
}
|
||||
return 'Paste failed.'
|
||||
}
|
||||
|
||||
/** The payload is gone by the time this shows, so the copy has to tell the user
|
||||
* what to do next rather than only what went wrong. */
|
||||
export function formatDeferredTerminalPasteDroppedError(
|
||||
platform: NodeJS.Platform = getShortcutPlatform()
|
||||
): string {
|
||||
const shortcut = platform === 'darwin' ? '⌘V' : 'Ctrl+V'
|
||||
return `Paste cancelled: terminal focus did not return in time. Click the terminal and press ${shortcut} to paste again.`
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ export function isTerminalPanePasteFocusCurrent({
|
||||
return paneContainer.contains(activeElement) && isXtermHelperTextarea(activeElement)
|
||||
}
|
||||
|
||||
function isInertDocumentFocus(element: Element | null): boolean {
|
||||
export function isInertDocumentFocus(element: Element | null): boolean {
|
||||
if (!element) {
|
||||
return true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user