fix(terminal): preserve paste across xterm focus replacement

This commit is contained in:
Brennan Benson
2026-08-27 09:37:39 -07:00
parent 92536346bd
commit 44b98f3706
3 changed files with 63 additions and 16 deletions
@@ -227,8 +227,10 @@ import { formatTerminalPasteExecutionError } from './terminal-paste-errors'
import { resolveTerminalPasteRuntime } from './terminal-paste-runtime'
import { getTerminalPasteSshRemotePlatform } from './terminal-paste-ssh-platform'
import {
captureTerminalPanePasteFocusSnapshot,
isTerminalPanePasteFocusCurrent,
isTerminalPanePasteTargetCurrent
isTerminalPanePasteTargetCurrent,
type TerminalPanePasteFocusSnapshot
} from './terminal-paste-target-state'
import { writeTerminalPastePtyInput } from './terminal-pty-paste-writer'
import {
@@ -1952,7 +1954,7 @@ function TerminalPane(
const executePanePasteText = async (
pane: ManagedPane,
source: TerminalPasteSource,
activeElementAtDispatch: Element | null,
focusAtDispatch: TerminalPanePasteFocusSnapshot,
text: string,
options?: TerminalPasteTextOptions
): Promise<void> => {
@@ -1993,7 +1995,7 @@ function TerminalPane(
}
return isTerminalPanePasteFocusCurrent({
requireSameFocusedElement: keyboardOwnedPaste,
activeElementAtDispatch,
focusAtDispatch,
paneContainer: pane.container
})
},
@@ -2044,7 +2046,7 @@ function TerminalPane(
useAppStore.getState(),
worktreeId
)
const activeElementAtDispatch = document.activeElement
const focusAtDispatch = captureTerminalPanePasteFocusSnapshot(pane.container)
void pasteTerminalClipboard({
readClipboardText,
saveClipboardImageAsTempFile: window.api.ui.saveClipboardImageAsTempFile,
@@ -2052,7 +2054,7 @@ function TerminalPane(
runtimeEnvironmentId,
protectedMultilineTextPasteOptions: resolvePaneProtectedMultilinePasteOptions(pane),
pasteText: (text, options) =>
executePanePasteText(pane, source, activeElementAtDispatch, text, options),
executePanePasteText(pane, source, focusAtDispatch, text, options),
onTextPasteError: () =>
setTerminalError('Paste failed: clipboard text is too large for a safe terminal paste.'),
onImagePasteError: (error) => setTerminalError(formatClipboardImagePasteError(error))
@@ -2187,6 +2189,10 @@ function TerminalPane(
if (!pane) {
return
}
const focusAtDispatch = captureTerminalPanePasteFocusSnapshot(
pane.container,
activeElementAtDispatch
)
const connectionId = getConnectionId(worktreeId) ?? null
const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(
useAppStore.getState(),
@@ -2199,7 +2205,7 @@ function TerminalPane(
runtimeEnvironmentId,
protectedMultilineTextPasteOptions: resolvePaneProtectedMultilinePasteOptions(pane),
pasteText: (text, options) =>
executePanePasteText(pane, 'app-menu', activeElementAtDispatch, text, options),
executePanePasteText(pane, 'app-menu', focusAtDispatch, text, options),
onTextPasteError: () =>
setTerminalError('Paste failed: clipboard text is too large for a safe terminal paste.'),
onImagePasteError: (error) => setTerminalError(formatClipboardImagePasteError(error))
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import {
captureTerminalPanePasteFocusSnapshot,
isTerminalPanePasteFocusCurrent,
isTerminalPanePasteTargetCurrent
} from './terminal-paste-target-state'
@@ -121,7 +122,7 @@ describe('terminal paste target state', () => {
expect(
isTerminalPanePasteFocusCurrent({
requireSameFocusedElement: true,
activeElementAtDispatch: terminalInput,
focusAtDispatch: { element: terminalInput, ownedByPane: true },
paneContainer,
activeElement: terminalInput
})
@@ -136,7 +137,7 @@ describe('terminal paste target state', () => {
expect(
isTerminalPanePasteFocusCurrent({
requireSameFocusedElement: true,
activeElementAtDispatch: terminalInput,
focusAtDispatch: { element: terminalInput, ownedByPane: true },
paneContainer,
activeElement: renameInput
})
@@ -151,7 +152,7 @@ describe('terminal paste target state', () => {
expect(
isTerminalPanePasteFocusCurrent({
requireSameFocusedElement: true,
activeElementAtDispatch: terminalInput,
focusAtDispatch: { element: terminalInput, ownedByPane: true },
paneContainer,
activeElement: body
})
@@ -161,18 +162,42 @@ describe('terminal paste target state', () => {
it('keeps keyboard-owned paste current when xterm replaces its helper textarea', () => {
const originalTerminalInput = makeElement('textarea', ['xterm-helper-textarea'])
const replacementTerminalInput = makeElement('textarea', ['xterm-helper-textarea'])
const paneContainer = makePaneContainerFor([originalTerminalInput, replacementTerminalInput])
let acceptedElements: readonly Element[] = [originalTerminalInput]
const paneContainer = {
contains: vi.fn((element: Element | null) => acceptedElements.includes(element as Element))
} as unknown as Element
const focusAtDispatch = captureTerminalPanePasteFocusSnapshot(
paneContainer,
originalTerminalInput
)
acceptedElements = [replacementTerminalInput]
expect(focusAtDispatch.ownedByPane).toBe(true)
expect(
isTerminalPanePasteFocusCurrent({
requireSameFocusedElement: true,
activeElementAtDispatch: originalTerminalInput,
focusAtDispatch,
paneContainer,
activeElement: replacementTerminalInput
})
).toBe(true)
})
it('rejects a replacement helper when the dispatch focus was never owned by the pane', () => {
const outsideInput = makeElement('input')
const replacementTerminalInput = makeElement('textarea', ['xterm-helper-textarea'])
const paneContainer = makePaneContainerFor([replacementTerminalInput])
expect(
isTerminalPanePasteFocusCurrent({
requireSameFocusedElement: true,
focusAtDispatch: { element: outsideInput, ownedByPane: false },
paneContainer,
activeElement: replacementTerminalInput
})
).toBe(false)
})
it('rejects keyboard-owned paste when focus moves to another control in the pane', () => {
const terminalInput = makeElement('textarea', ['xterm-helper-textarea'])
const searchInput = makeElement('input')
@@ -181,7 +206,7 @@ describe('terminal paste target state', () => {
expect(
isTerminalPanePasteFocusCurrent({
requireSameFocusedElement: true,
activeElementAtDispatch: terminalInput,
focusAtDispatch: { element: terminalInput, ownedByPane: true },
paneContainer,
activeElement: searchInput
})
@@ -196,7 +221,7 @@ describe('terminal paste target state', () => {
expect(
isTerminalPanePasteFocusCurrent({
requireSameFocusedElement: false,
activeElementAtDispatch: terminalInput,
focusAtDispatch: { element: terminalInput, ownedByPane: true },
paneContainer,
activeElement: otherInput
})
@@ -38,23 +38,39 @@ export function isTerminalPanePasteTargetCurrent({
)
}
export type TerminalPanePasteFocusSnapshot = {
element: Element | null
ownedByPane: boolean
}
export function captureTerminalPanePasteFocusSnapshot(
paneContainer: Element,
element: Element | null = typeof document === 'undefined' ? null : document.activeElement
): TerminalPanePasteFocusSnapshot {
return {
element,
ownedByPane: element === null || paneContainer.contains(element)
}
}
export type TerminalPanePasteFocusState = {
requireSameFocusedElement: boolean
activeElementAtDispatch: Element | null
focusAtDispatch: TerminalPanePasteFocusSnapshot
paneContainer: Element
activeElement?: Element | null
}
export function isTerminalPanePasteFocusCurrent({
requireSameFocusedElement,
activeElementAtDispatch,
focusAtDispatch,
paneContainer,
activeElement = typeof document === 'undefined' ? null : document.activeElement
}: TerminalPanePasteFocusState): boolean {
const activeElementAtDispatch = focusAtDispatch.element
if (!requireSameFocusedElement || activeElementAtDispatch === null) {
return true
}
if (!paneContainer.contains(activeElementAtDispatch)) {
if (!focusAtDispatch.ownedByPane) {
return false
}
if (activeElement === activeElementAtDispatch) {