diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 8017a78f2fb..a57234db9ca 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -2042,6 +2042,7 @@ function TerminalPane( isDeferrablePasteFocusCancellation({ status: execution.status, reason: execution.reason, + chunksWritten: execution.chunksWritten, targetMounted: isPanePasteTargetMounted(pane, transport, ptyId), focusMovedToOtherPane: isFocusInsideOtherPane({ panes: managerRef.current?.getPanes() ?? [], 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 index cc7d1f2482e..a8ac228e04c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-deferred-paste.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-deferred-paste.test.ts @@ -1,6 +1,7 @@ // @vitest-environment happy-dom import { describe, expect, it, vi, type Mock } from 'vitest' +import { executeTerminalPastePlan, planTerminalPaste } from './terminal-paste-coordinator' import { createDeferredPasteFocusInHandler, createDeferredTerminalPasteQueue, @@ -144,6 +145,46 @@ describe('deferred terminal paste queue', () => { ) }) + // STA-5272 review: Electron leaves `backgroundThrottling` on, so a hidden + // renderer's timers are aligned to 1s and, after 5 minutes hidden, to 1-minute + // buckets. The deadline timer can therefore still be pending long past 2s while + // restoring the window fires focusin first — landing the paste minutes later. + // The wall clock, not the timer, has to be what bounds the payload. + it('refuses a claim made after the deadline even if the timer has not fired yet', () => { + const timers = createTimerHarness() + const onExpire = vi.fn() + let nowMs = 1_000 + const queue = createDeferredTerminalPasteQueue({ + onExpire, + now: () => nowMs, + ...timers + }) + + queue.defer(entry()) + // The window was hidden; the timer is still queued, unfired. + nowMs += TERMINAL_DEFERRED_PASTE_TIMEOUT_MS + 1 + expect(timers.live()).toHaveLength(1) + + expect(queue.claim(1, 'leaf-a')).toBe(null) + // and the user is told rather than silently robbed of the paste + expect(onExpire).toHaveBeenCalledTimes(1) + expect(queue.isPending()).toBe(false) + expect(timers.live()).toEqual([]) + }) + + it('still honours a claim made inside the deadline', () => { + const timers = createTimerHarness() + const onExpire = vi.fn() + let nowMs = 1_000 + const queue = createDeferredTerminalPasteQueue({ onExpire, now: () => nowMs, ...timers }) + + queue.defer(entry()) + nowMs += TERMINAL_DEFERRED_PASTE_TIMEOUT_MS - 1 + + expect(queue.claim(1, 'leaf-a')).toEqual(entry()) + expect(onExpire).not.toHaveBeenCalled() + }) + it('drops the payload on dispose without firing the expiry signal', () => { const timers = createTimerHarness() const onExpire = vi.fn() @@ -162,6 +203,7 @@ describe('isDeferrablePasteFocusCancellation', () => { const base = { status: 'cancelled' as const, reason: 'stale-target' as const, + chunksWritten: 0, targetMounted: true, focusMovedToOtherPane: false } @@ -179,6 +221,16 @@ describe('isDeferrablePasteFocusCancellation', () => { expect(isDeferrablePasteFocusCancellation({ ...base, targetMounted: false })).toBe(false) }) + // Ablation gap: every other case here is already excluded by the reason check, + // so the status conjunct needs a reason it would otherwise let through. Today + // the executor only pairs 'rejected' with 'payload-too-large' (see + // terminal-paste-coordinator planning), so this pins the contract, not a + // currently reachable pairing. + it('refuses to defer anything that is not a cancellation, whatever the reason says', () => { + expect(isDeferrablePasteFocusCancellation({ ...base, status: 'rejected' })).toBe(false) + expect(isDeferrablePasteFocusCancellation({ ...base, status: 'pasted' })).toBe(false) + }) + it('refuses to defer non-focus cancellations and rejections', () => { expect(isDeferrablePasteFocusCancellation({ ...base, reason: 'target-disconnected' })).toBe( false @@ -410,3 +462,181 @@ describe('deferred paste focusin handler', () => { expect(scene.onDropped).not.toHaveBeenCalled() }) }) + +// STA-5272 review: `stale-target` is not only a pre-write verdict. The chunked +// writer re-checks the target between chunks, so a focus move part-way through a +// large paste cancels with bytes already in the PTY. Deferring that payload and +// replaying it whole duplicates everything written before the cancel. +describe('partially written pastes are not deferrable', () => { + const chunkedPlan = (text: string) => + planTerminalPaste({ + text, + source: 'keyboard', + target: { + kind: 'terminal', + paneId: 1, + leafId: 'leaf-a', + ptyId: 'pty-1', + runtime: { platform: 'darwin', runtimeKey: 'local:darwin', kind: 'local' } + }, + maxDirectBytes: 4, + maxChunkBytes: 8 + }) + + it('premise: the real executor cancels mid-write with bytes already in the pty', async () => { + const writes: string[] = [] + let focusHeld = true + + const execution = await executeTerminalPastePlan(chunkedPlan('abcdefgh12345678zzzzzzzz'), { + pasteText: () => {}, + writePty: (data) => { + writes.push(data) + return true + }, + // Focus leaves the pane after the first chunk lands, exactly as a + // dictation/overlay handoff does mid-paste. + isTargetCurrent: () => { + if (writes.length >= 1) { + focusHeld = false + } + return focusHeld + } + }) + + expect(execution.status).toBe('cancelled') + expect(execution.reason).toBe('stale-target') + expect(execution.chunksWritten).toBeGreaterThan(0) + expect(writes.join('')).not.toBe('abcdefgh12345678zzzzzzzz') + }) + + it('refuses to defer a cancellation that already wrote bytes to the pty', () => { + expect( + isDeferrablePasteFocusCancellation({ + status: 'cancelled', + reason: 'stale-target', + chunksWritten: 3, + targetMounted: true, + focusMovedToOtherPane: false + }) + ).toBe(false) + }) + + it('still defers a cancellation that wrote nothing', () => { + expect( + isDeferrablePasteFocusCancellation({ + status: 'cancelled', + reason: 'stale-target', + chunksWritten: 0, + targetMounted: true, + focusMovedToOtherPane: false + }) + ).toBe(true) + }) +}) + +// STA-5272 review: the pane the payload was aimed at can be closed while the +// payload waits. Nothing may be written anywhere, and the user has to be told +// now rather than after the full deadline, because the text is still retained. +describe('a deferred paste whose pane is destroyed before focus returns', () => { + const buildScene = (): { + panes: { id: number; leafId: string; container: HTMLElement }[] + ptyWrites: string[] + queue: ReturnType + handler: () => void + onDropped: Mock<(deferred: DeferredTerminalPaste) => void> + onExpire: Mock<(deferred: DeferredTerminalPaste) => void> + outside: HTMLInputElement + } => { + 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 outside = document.createElement('input') + document.body.append(outside) + const ptyWrites: string[] = [] + const onExpire = vi.fn<(deferred: DeferredTerminalPaste) => void>() + const queue = createDeferredTerminalPasteQueue({ onExpire, ...createTimerHarness() }) + const onDropped = vi.fn<(deferred: DeferredTerminalPaste) => void>() + const handler = createDeferredPasteFocusInHandler({ + queue, + getPanes: () => panes, + getFocusedElement: () => document.activeElement, + // The only thing delivery does in production is run the paste, which ends + // in a PTY write; a spy that records nothing would hide a real write. + deliver: (_pane, deferred) => ptyWrites.push(deferred.text), + onDropped + }) + return { panes, ptyWrites, queue, handler, onDropped, onExpire, outside } + } + + it('drops the payload and tells the user when focus lands outside every pane', () => { + const scene = buildScene() + scene.queue.defer(entry({ paneId: 1, leafId: 'leaf-0', text: 'secret token' })) + + // The pane is closed: it is gone from the manager and out of the DOM. + scene.panes[0]!.container.remove() + scene.panes.splice(0, 1) + scene.outside.focus() + scene.handler() + + expect(scene.ptyWrites).toEqual([]) + expect(scene.onDropped).toHaveBeenCalledTimes(1) + expect(scene.onDropped.mock.calls[0]?.[0]?.text).toBe('secret token') + // Retention: the clipboard text is released with the pane, not held to the deadline. + expect(scene.queue.isPending()).toBe(false) + expect(scene.onExpire).not.toHaveBeenCalled() + }) + + it('drops the payload and tells the user when focus lands in a surviving sibling', () => { + const scene = buildScene() + scene.queue.defer(entry({ paneId: 1, leafId: 'leaf-0' })) + + scene.panes[0]!.container.remove() + scene.panes.splice(0, 1) + ;(scene.panes[0]!.container.firstElementChild as HTMLElement).focus() + scene.handler() + + expect(scene.ptyWrites).toEqual([]) + expect(scene.onDropped).toHaveBeenCalledTimes(1) + expect(scene.queue.isPending()).toBe(false) + }) + + it('leaves the payload alone while the pane manager reports no panes at all', () => { + // A transiently empty manager during a re-render is not evidence the target + // pane was closed; treating it as one would drop a healthy deferred paste. + const scene = buildScene() + scene.queue.defer(entry({ paneId: 1, leafId: 'leaf-0' })) + const panesSnapshot = [...scene.panes] + scene.panes.length = 0 + + scene.outside.focus() + scene.handler() + + expect(scene.onDropped).not.toHaveBeenCalled() + expect(scene.queue.isPending()).toBe(true) + + // and it still lands once the manager reports the pane again + scene.panes.push(...panesSnapshot) + ;(scene.panes[0]!.container.firstElementChild as HTMLElement).focus() + scene.handler() + expect(scene.ptyWrites).toEqual(['dictated paragraph']) + }) + + it('keeps a live pane pending so the destroy check cannot swallow a normal defer', () => { + const scene = buildScene() + scene.queue.defer(entry({ paneId: 1, leafId: 'leaf-0' })) + + scene.outside.focus() + scene.handler() + + expect(scene.onDropped).not.toHaveBeenCalled() + expect(scene.queue.isPending()).toBe(true) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-deferred-paste.ts b/src/renderer/src/components/terminal-pane/terminal-deferred-paste.ts index 659d2fe6a9b..0b048243efa 100644 --- a/src/renderer/src/components/terminal-pane/terminal-deferred-paste.ts +++ b/src/renderer/src/components/terminal-pane/terminal-deferred-paste.ts @@ -25,6 +25,9 @@ export type DeferredTerminalPasteQueue = { /** Drops the payload without firing the expiry callback; returns what was dropped. */ cancel: () => DeferredTerminalPaste | null isPending: () => boolean + /** Identity of the pending payload, never its text, so callers can check the + * target is still alive without a second copy of the clipboard escaping. */ + pendingTarget: () => { paneId: number; leafId: string } | null dispose: () => void } @@ -33,16 +36,19 @@ type CreateDeferredTerminalPasteQueueArgs = { timeoutMs?: number setTimer?: (callback: () => void, ms: number) => number clearTimer?: (timerId: number) => void + now?: () => number } export function createDeferredTerminalPasteQueue({ onExpire, timeoutMs = TERMINAL_DEFERRED_PASTE_TIMEOUT_MS, setTimer = (callback, ms) => window.setTimeout(callback, ms), - clearTimer = (timerId) => window.clearTimeout(timerId) + clearTimer = (timerId) => window.clearTimeout(timerId), + now = () => Date.now() }: CreateDeferredTerminalPasteQueueArgs): DeferredTerminalPasteQueue { let pending: DeferredTerminalPaste | null = null let timerId: number | null = null + let deadlineAtMs = 0 const take = (): DeferredTerminalPaste | null => { const taken = pending @@ -54,10 +60,25 @@ export function createDeferredTerminalPasteQueue({ return taken } + // Why: Electron leaves backgroundThrottling on, so a hidden renderer's timers + // are aligned into 1s (then 1-minute) buckets. The wall clock is what bounds + // the payload; the timer is only how the user gets told promptly. + const releaseIfExpired = (): boolean => { + if (!pending || now() < deadlineAtMs) { + return false + } + const expired = take() + if (expired) { + onExpire(expired) + } + return true + } + return { defer: (entry) => { take() pending = entry + deadlineAtMs = now() + timeoutMs timerId = setTimer(() => { timerId = null // Why: release the clipboard text before notifying, so a throwing @@ -70,13 +91,23 @@ export function createDeferredTerminalPasteQueue({ }, timeoutMs) }, claim: (paneId, leafId) => { + if (releaseIfExpired()) { + return null + } if (!pending || pending.paneId !== paneId || pending.leafId !== leafId) { return null } return take() }, cancel: () => take(), - isPending: () => pending !== null, + isPending: () => { + releaseIfExpired() + return pending !== null + }, + pendingTarget: () => { + releaseIfExpired() + return pending ? { paneId: pending.paneId, leafId: pending.leafId } : null + }, dispose: () => { take() } @@ -84,20 +115,29 @@ export function createDeferredTerminalPasteQueue({ } /** 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. */ + * live target and no other pane has taken focus — the case the guard exists for. + * `chunksWritten` is load-bearing: the chunked writer re-checks focus between + * chunks, so a cancel can arrive with bytes already in the PTY, and redelivering + * the whole payload would duplicate everything written before the cancel. */ export function isDeferrablePasteFocusCancellation({ status, reason, + chunksWritten, targetMounted, focusMovedToOtherPane }: { status: TerminalPasteExecutionResult['status'] reason: TerminalPasteExecutionReason | undefined + chunksWritten: number targetMounted: boolean focusMovedToOtherPane: boolean }): boolean { return ( - status === 'cancelled' && reason === 'stale-target' && targetMounted && !focusMovedToOtherPane + status === 'cancelled' && + reason === 'stale-target' && + chunksWritten === 0 && + targetMounted && + !focusMovedToOtherPane ) } @@ -110,7 +150,7 @@ type DeferredPasteFocusPane = { export type DeferredPasteFocusResolution = | { action: 'ignore' } | { action: 'deliver'; pane: TPane; entry: DeferredTerminalPaste } - | { action: 'drop'; pane: TPane; entry: DeferredTerminalPaste | null } + | { action: 'drop'; pane: TPane | null; 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. */ @@ -123,10 +163,20 @@ export function resolveDeferredPasteFocusIn { - if (!queue.isPending() || !focusedElement) { + const target = queue.pendingTarget() + if (!target || !focusedElement) { return { action: 'ignore' } } const pane = panes.find((candidate) => candidate.container.contains(focusedElement)) + // Why: the target pane was closed while the payload waited. Release the + // clipboard text now rather than retaining it for a pane that cannot receive + // it; `panes.length` guards a transiently empty manager during a re-render. + const targetGone = + panes.length > 0 && + !panes.some((candidate) => candidate.id === target.paneId && candidate.leafId === target.leafId) + if (targetGone) { + return { action: 'drop', pane: pane ?? null, entry: queue.cancel() } + } if (!pane) { return { action: 'ignore' } } 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 index 2a3ab31aa6c..6908447a7d4 100644 --- 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 @@ -178,6 +178,80 @@ describe('inert-focus paste fallback', () => { expect(fallback.ownsInertFocus()).toBe(false) }) + // STA-5272 review: the pane can stop being rendered while `isActive` stays + // true (a CSS-hidden floating panel, a display:none background surface). The + // browser blurs its focus to WITHOUT a focusin, so `paneOwnsFocus` + // stays stale. These listeners sit at the document in capture phase, so a + // stale claim swallows the chord (preventDefault + stopPropagation) for the + // whole app and routes the paste into an invisible terminal. + it('does not claim inert focus once the pane is no longer rendered', () => { + const fallback = install() + inside.focus() + inside.blur() + expect(fallback.ownsInertFocus()).toBe(true) + + // Exactly how the closed floating terminal panel hides its subtree. + container.style.visibility = 'hidden' + + expect(fallback.ownsInertFocus()).toBe(false) + }) + + it('does not swallow the paste chord for the rest of the app while hidden', () => { + install() + inside.focus() + inside.blur() + container.style.visibility = 'hidden' + + pressPasteChordOn(document.body) + document.body.dispatchEvent(new Event('paste', { bubbles: true, cancelable: true })) + + expect(onPasteKey).not.toHaveBeenCalled() + expect(onPasteEvent).not.toHaveBeenCalled() + }) + + it('does not claim inert focus after its container leaves the document', () => { + const fallback = install() + inside.focus() + inside.blur() + container.remove() + + expect(fallback.ownsInertFocus()).toBe(false) + pressPasteChordOn(document.body) + expect(onPasteKey).not.toHaveBeenCalled() + }) + + it('honours the browser visibility check that covers display:none subtrees', () => { + // happy-dom has no checkVisibility; production Chromium does, and it is the + // only check that sees a display:none ancestor. Pin that we call it. + const calls: unknown[] = [] + ;(container as HTMLElement & { checkVisibility?: (o?: unknown) => boolean }).checkVisibility = ( + options + ) => { + calls.push(options) + return false + } + const fallback = install() + inside.focus() + inside.blur() + + expect(fallback.ownsInertFocus()).toBe(false) + expect(calls.length).toBeGreaterThan(0) + pressPasteChordOn(document.body) + expect(onPasteKey).not.toHaveBeenCalled() + }) + + it('still recovers when the browser reports the container as visible', () => { + ;(container as HTMLElement & { checkVisibility?: (o?: unknown) => boolean }).checkVisibility = + () => true + install() + inside.focus() + inside.blur() + + pressPasteChordOn(document.body) + + expect(onPasteKey).toHaveBeenCalledTimes(1) + }) + it('stops recovering pastes after dispose', () => { const fallback = install() inside.focus() 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 index b5f00cfe08a..2b230b3ec91 100644 --- 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 @@ -37,10 +37,16 @@ export function installInertFocusPasteFallback({ paneOwnsFocus = false } } + // Order matters: these run at the document on every keystroke in the app, once + // per mounted pane. The two boolean checks short-circuit before any DOM walk, + // and the layout-touching visibility check only runs for the at-most-one pane + // that both owns focus and has focus sitting on . const ownsInertFocus = (): boolean => - paneOwnsFocus && isInertDocumentFocus(documentTarget.activeElement) + paneOwnsFocus && + isInertDocumentFocus(documentTarget.activeElement) && + isContainerRendered(container) const shouldRecover = (event: Event): boolean => - !containsNode(container, event.target) && ownsInertFocus() + ownsInertFocus() && !containsNode(container, event.target) const onKeyDown = (event: KeyboardEvent): void => { if (shouldRecover(event)) { @@ -73,6 +79,27 @@ function containsNode(container: HTMLElement, node: EventTarget | Node | null): return node instanceof Node && container.contains(node) } +/** A pane that stopped being rendered (CSS-hidden floating panel, display:none + * background surface) was blurred to `` by the browser with no focusin, so + * `paneOwnsFocus` stays stale. Claiming on that swallows the chord for the whole + * app at the capture phase and routes the paste into an invisible terminal. */ +function isContainerRendered(container: HTMLElement): boolean { + if (!container.isConnected) { + return false + } + const checkVisibility = ( + container as HTMLElement & { + checkVisibility?: (options?: { visibilityProperty?: boolean }) => boolean + } + ).checkVisibility + if (typeof checkVisibility === 'function') { + return checkVisibility.call(container, { visibilityProperty: true }) + } + // Fallback for engines without checkVisibility; `visibility` is inherited, so + // reading it off the container also catches a hidden ancestor. + return container.ownerDocument.defaultView?.getComputedStyle(container).visibility !== 'hidden' +} + /** 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(