From 487a0489d46aa2fa37485b8bf34f8cb1fe09b5b9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 28 Aug 2026 05:17:06 -0700 Subject: [PATCH] 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 - 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 --- .../components/terminal-pane/TerminalPane.tsx | 119 ++++- ...minal-clipboard-paste-read-failure.test.ts | 90 ++++ .../terminal-pane/terminal-clipboard-paste.ts | 14 +- .../terminal-deferred-paste.test.ts | 412 ++++++++++++++++++ .../terminal-pane/terminal-deferred-paste.ts | 187 ++++++++ ...erminal-inert-focus-paste-fallback.test.ts | 230 ++++++++++ .../terminal-inert-focus-paste-fallback.ts | 85 ++++ .../terminal-pane/terminal-pane-menu-paste.ts | 8 +- .../terminal-paste-errors.test.ts | 42 ++ .../terminal-pane/terminal-paste-errors.ts | 13 + .../terminal-paste-target-state.ts | 2 +- 11 files changed, 1192 insertions(+), 10 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-clipboard-paste-read-failure.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-deferred-paste.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-deferred-paste.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-inert-focus-paste-fallback.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-inert-focus-paste-fallback.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-paste-errors.test.ts diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 2b58732f0ea..8017a78f2fb 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -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(null) const [terminalError, setTerminalError] = useState(null) + // Why: outlives the paste effect so a keybinding change mid-deferral does not + // silently drop the payload; only unmount does. + const deferredPasteRef = useRef(null) + useEffect(() => { + return () => { + deferredPasteRef.current?.dispose() + deferredPasteRef.current = null + } + }, []) const [paneProcessExitsByPaneId, setPaneProcessExitsByPaneId] = useState< Record >({}) @@ -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 => { 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 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({ + 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) } diff --git a/src/renderer/src/components/terminal-pane/terminal-clipboard-paste-read-failure.test.ts b/src/renderer/src/components/terminal-pane/terminal-clipboard-paste-read-failure.test.ts new file mode 100644 index 00000000000..ad6e2f574a5 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-clipboard-paste-read-failure.test.ts @@ -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() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-clipboard-paste.ts b/src/renderer/src/components/terminal-pane/terminal-clipboard-paste.ts index abd0fb95b97..03a0f76c4fa 100644 --- a/src/renderer/src/components/terminal-pane/terminal-clipboard-paste.ts +++ b/src/renderer/src/components/terminal-pane/terminal-clipboard-paste.ts @@ -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 { 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, { diff --git a/src/renderer/src/components/terminal-pane/terminal-deferred-paste.test.ts b/src/renderer/src/components/terminal-pane/terminal-deferred-paste.test.ts new file mode 100644 index 00000000000..cc7d1f2482e --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-deferred-paste.test.ts @@ -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() + 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 => ({ + 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 + deliver: Mock<(pane: { id: number }, deferred: DeferredTerminalPaste) => void> + onDropped: Mock<(deferred: DeferredTerminalPaste) => void> + onExpire: Mock<(deferred: DeferredTerminalPaste) => void> + timers: ReturnType + 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() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-deferred-paste.ts b/src/renderer/src/components/terminal-pane/terminal-deferred-paste.ts new file mode 100644 index 00000000000..659d2fe6a9b --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-deferred-paste.ts @@ -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 = + | { 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({ + panes, + focusedElement, + queue +}: { + panes: readonly TPane[] + focusedElement: Node | null + queue: DeferredTerminalPasteQueue +}): DeferredPasteFocusResolution { + 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({ + 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({ + 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) + } + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-inert-focus-paste-fallback.test.ts b/src/renderer/src/components/terminal-pane/terminal-inert-focus-paste-fallback.test.ts new file mode 100644 index 00000000000..2a3ab31aa6c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-inert-focus-paste-fallback.test.ts @@ -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 , +// 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 => { + 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() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-inert-focus-paste-fallback.ts b/src/renderer/src/components/terminal-pane/terminal-inert-focus-paste-fallback.ts new file mode 100644 index 00000000000..b5f00cfe08a --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-inert-focus-paste-fallback.ts @@ -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 `` rather than moving to another surface. */ + ownsInertFocus: () => boolean + dispose: () => void +} + +/** A dictation tool, IME, or overlay hands focus back to ``, 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 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 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() + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-menu-paste.ts b/src/renderer/src/components/terminal-pane/terminal-pane-menu-paste.ts index 295b7e35c53..b4f441e471e 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-menu-paste.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-menu-paste.ts @@ -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 diff --git a/src/renderer/src/components/terminal-pane/terminal-paste-errors.test.ts b/src/renderer/src/components/terminal-pane/terminal-paste-errors.test.ts new file mode 100644 index 00000000000..1ec7fcb37b9 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-paste-errors.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-paste-errors.ts b/src/renderer/src/components/terminal-pane/terminal-paste-errors.ts index 8a2114cfe93..42480feaab8 100644 --- a/src/renderer/src/components/terminal-pane/terminal-paste-errors.ts +++ b/src/renderer/src/components/terminal-pane/terminal-paste-errors.ts @@ -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.` +} diff --git a/src/renderer/src/components/terminal-pane/terminal-paste-target-state.ts b/src/renderer/src/components/terminal-pane/terminal-paste-target-state.ts index 1f42adfaae9..a9671dc4996 100644 --- a/src/renderer/src/components/terminal-pane/terminal-paste-target-state.ts +++ b/src/renderer/src/components/terminal-pane/terminal-paste-target-state.ts @@ -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 }