From ea7c94faef45600e7efb6a39bd560cb912e7cf3d Mon Sep 17 00:00:00 2001 From: Pr1p Date: Sat, 14 Nov 2026 10:05:11 +0800 Subject: [PATCH] fix(terminal): aim app-menu paste at the pane owning focus (#41585) Edit > Paste dispatches without a renderer keydown, so it skipped the keydown active-pane repair and targeted manager.getActivePane(). When focus reached another pane's xterm without a pointerdown (for example a file drop from the explorer onto an inactive split), the paste was cancelled with "Paste cancelled: terminal focus changed before paste started." Resolve the pane from the dispatch-time focus with the same synchronizeTerminalKeyboardPane helper keydown uses. Refs #41352 Co-authored-by: Neil --- .../terminal-pane-paste-listeners.test.ts | 110 ++++++++++++++++++ .../paste/terminal-pane-paste-listeners.ts | 4 +- 2 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/components/terminal-pane/input/paste/terminal-pane-paste-listeners.test.ts diff --git a/src/renderer/src/components/terminal-pane/input/paste/terminal-pane-paste-listeners.test.ts b/src/renderer/src/components/terminal-pane/input/paste/terminal-pane-paste-listeners.test.ts new file mode 100644 index 00000000000..2f2d73ca222 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/input/paste/terminal-pane-paste-listeners.test.ts @@ -0,0 +1,110 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { TerminalPaneCloseController } from '../../layout/close/use-terminal-pane-close-actions' +import { APP_MENU_PASTE_EVENT } from '@/lib/clipboard/menu/app-menu-paste' +import { createTerminalPanePasteExecution } from './terminal-pane-paste-execution' +import { registerTerminalPanePasteListeners } from './terminal-pane-paste-listeners' + +vi.mock('@/store/index', () => ({ + useAppStore: { + getState: () => ({ + recordTerminalInput: vi.fn(), + agentStatusByPaneKey: {}, + paneForegroundAgentByPaneKey: {}, + runtimeStatusByEnvironmentId: new Map(), + repos: [], + worktreesByRepo: {} + }) + } +})) + +function createFakePane(id: number, root: HTMLElement) { + const container = document.createElement('div') + const element = document.createElement('div') + const textarea = document.createElement('textarea') + textarea.className = 'xterm-helper-textarea' + element.appendChild(textarea) + container.appendChild(element) + root.appendChild(container) + return { + id, + leafId: `leaf-${id}`, + container, + textarea, + terminal: { + modes: { bracketedPasteMode: false }, + options: {}, + element, + paste: vi.fn(), + input: vi.fn() + } + } +} + +describe('registerTerminalPanePasteListeners app-menu paste', () => { + let cleanup: (() => void) | null = null + + afterEach(() => { + cleanup?.() + cleanup = null + document.body.replaceChildren() + }) + + it('pastes into the focused pane when the manager active pane is stale (#41352)', async () => { + Object.assign(window, { + api: { + ui: { + readClipboardText: vi.fn(async () => 'echo hi'), + readClipboardFilePaths: vi.fn(async () => []), + clipboardHasImage: vi.fn(async () => false), + saveClipboardImageAsTempFile: vi.fn() + } + } + }) + const container = document.createElement('div') + document.body.appendChild(container) + const paneA = createFakePane(1, container) + const paneB = createFakePane(2, container) + let activePane = paneA + const manager = { + getPanes: () => [paneA, paneB], + getActivePane: () => activePane, + setActivePane: (id: number) => { + activePane = id === paneB.id ? paneB : paneA + } + } + const transports = new Map( + [paneA, paneB].map((pane) => [ + pane.id, + { getPtyId: () => `pty-${pane.id}`, isConnected: () => true } + ]) + ) + const setTerminalError = vi.fn() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: stub supplies the controller fields the paste listeners and execution read. + const controller = { + cwd: '/tmp', + forceBracketedMultilineTextPaste: false, + keybindings: {}, + managerRef: { current: manager }, + paneTransportsRef: { current: transports }, + setTerminalError, + tabId: 'tab-1', + worktreeId: 'wt-1' + } as unknown as TerminalPaneCloseController + cleanup = registerTerminalPanePasteListeners({ + container, + controller, + execution: createTerminalPanePasteExecution(controller, 'darwin'), + isMac: true, + shortcutPlatform: 'darwin' + }) + + // A file drop focuses the drop pane's xterm without a pointerdown, leaving pane A active. + paneB.textarea.focus() + window.dispatchEvent(new CustomEvent(APP_MENU_PASTE_EVENT, { cancelable: true })) + + await vi.waitFor(() => expect(paneB.terminal.paste).toHaveBeenCalledWith('echo hi')) + expect(paneA.terminal.paste).not.toHaveBeenCalled() + expect(setTerminalError).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/input/paste/terminal-pane-paste-listeners.ts b/src/renderer/src/components/terminal-pane/input/paste/terminal-pane-paste-listeners.ts index 8826680197e..2a8d5a1f14a 100644 --- a/src/renderer/src/components/terminal-pane/input/paste/terminal-pane-paste-listeners.ts +++ b/src/renderer/src/components/terminal-pane/input/paste/terminal-pane-paste-listeners.ts @@ -18,6 +18,7 @@ import { import { isEditableTarget } from '@/lib/text-input/focus/editable-target' import { copyTerminalSelection } from '../../clipboard/terminal-selection-copy' import { isInsideNativeChatCover } from '../../chat/native-chat-covered-pane' +import { synchronizeTerminalKeyboardPane } from '../keyboard/terminal-keyboard-pane-resolution' import type { TerminalPaneCloseController } from '../../layout/close/use-terminal-pane-close-actions' import { formatClipboardImagePasteError, @@ -173,7 +174,8 @@ export function registerTerminalPanePasteListeners({ if (!manager) { return } - const pane = manager.getActivePane() ?? manager.getPanes()[0] + // Menu pastes skip keydown's active-pane repair, so focus moved without a pointerdown (e.g. file drop) would target a stale pane. + const pane = synchronizeTerminalKeyboardPane(manager, activeElementAtDispatch) if (!pane) { return }