diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 2b58732f0ea..647835068eb 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -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 => { @@ -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)) diff --git a/src/renderer/src/components/terminal-pane/terminal-paste-target-state.test.ts b/src/renderer/src/components/terminal-pane/terminal-paste-target-state.test.ts index 4d35ef7cfbe..b5ab747aec0 100644 --- a/src/renderer/src/components/terminal-pane/terminal-paste-target-state.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-paste-target-state.test.ts @@ -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 }) 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..0ffdbeb8a7a 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 @@ -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) {