fix(terminal): stop native middle-click paste inserting twice on Linux (#8993)

* fix(terminal): stop middle-click primary paste inserting twice on X11

When "Middle-click Paste from Selection" is enabled, the integrated
terminal reads the X11 PRIMARY selection on mousedown and writes it to
the PTY itself. It calls preventDefault on the mousedown, but Chromium's
native X11 middle-click primary paste fires on mouse release regardless,
landing in xterm's helper textarea, which xterm then forwards to the PTY
a second time. The result is the selection pasted twice.

The global primary-selection hook already suppresses the follow-up native
paste, but only for editable DOM targets it owns via a pending-target
handle; it deliberately excludes xterm, so the terminal path had no
suppression at all.

Arm a short shared suppression window when the terminal handles a
middle-click, and have the global hook's capture-phase beforeinput/paste
suppressor honor it. This swallows the single native paste event so the
selection reaches the PTY exactly once. Ctrl+Shift+V and right-click
paste are unaffected: they use the CLIPBOARD and never emit a native
primary-paste event.

Closes #8860

* docs(terminal): clarify native middle-click paste suppression

* fix(terminal): scope primary-selection native-paste suppression to Linux xterm

Gate the armed native-paste suppression window to Linux (X11 primary-selection
path) and scope it to xterm's helper textarea so unrelated document pastes and
non-Linux platforms are never affected. Re-arm on auxclick for slow releases.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Mark Xian 2026-07-24 15:25:21 +08:00 committed by GitHub
parent 9ced27eca8
commit eefded2a04
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 182 additions and 6 deletions

View File

@ -124,7 +124,11 @@ import {
closeWebRuntimeTerminal,
updateWebRuntimePaneLayout
} from '@/runtime/web-runtime-session'
import { isPrimarySelectionEnabled, readPrimarySelectionText } from '@/lib/primary-selection'
import {
armPrimarySelectionNativePasteSuppression,
isPrimarySelectionEnabled,
readPrimarySelectionText
} from '@/lib/primary-selection'
import { APP_MENU_PASTE_EVENT } from '@/lib/app-menu-paste'
import { WORKSPACE_FILE_PATH_MIME, WORKSPACE_FILE_PATHS_MIME } from '@/lib/workspace-file-drag'
import { isTerminalSessionStateSaveFailure } from '../../../../shared/terminal-session-state-save-failure'
@ -2627,6 +2631,10 @@ export default function TerminalPane({
}
event.preventDefault()
event.stopPropagation()
// Why: preventDefault on mousedown does not stop Chromium's native
// middle-click paste follow-up, so arm the shared window to swallow it and
// avoid inserting text into the PTY twice.
armPrimarySelectionNativePasteSuppression()
clickedPane.terminal.focus()
void readPrimarySelectionText().then(async (text) => {
if (!text) {
@ -2700,6 +2708,10 @@ export default function TerminalPane({
) {
event.preventDefault()
event.stopPropagation()
// Why: auxclick fires at button release, when Chromium's native paste is
// imminent; re-arm here so a slow release past the mousedown window still
// swallows the follow-up paste.
armPrimarySelectionNativePasteSuppression()
}
},
[getPrimarySelectionMiddleClickPane]

View File

@ -3,17 +3,22 @@
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { readPrimarySelectionText } from '@/lib/primary-selection'
import {
readPrimarySelectionText,
shouldSuppressPrimarySelectionNativePaste
} from '@/lib/primary-selection'
import { usePrimarySelectionPaste } from './usePrimarySelectionPaste'
vi.mock('@/lib/primary-selection', () => ({
readPrimarySelectionText: vi.fn(),
setPrimarySelectionEnabled: vi.fn(),
setPrimarySelectionText: vi.fn()
setPrimarySelectionText: vi.fn(),
shouldSuppressPrimarySelectionNativePaste: vi.fn(() => false)
}))
const originalUserAgent = navigator.userAgent
const readPrimarySelectionTextMock = vi.mocked(readPrimarySelectionText)
const shouldSuppressNativePasteMock = vi.mocked(shouldSuppressPrimarySelectionNativePaste)
let root: Root | null = null
let container: HTMLDivElement | null = null
@ -37,6 +42,17 @@ function appendTextarea(value = ''): HTMLTextAreaElement {
return textarea
}
function appendXtermHelperTextarea(): HTMLTextAreaElement {
// Stand-in for xterm's hidden helper textarea inside its `.xterm` container.
const terminal = document.createElement('div')
terminal.className = 'xterm'
const textarea = document.createElement('textarea')
textarea.className = 'xterm-helper-textarea'
terminal.appendChild(textarea)
document.body.appendChild(terminal)
return textarea
}
function dispatchMiddleMouseDown(target: HTMLElement): MouseEvent {
const event = new MouseEvent('mousedown', { bubbles: true, button: 1, cancelable: true })
target.dispatchEvent(event)
@ -107,6 +123,7 @@ async function renderProbe(): Promise<void> {
beforeEach(() => {
setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)')
shouldSuppressNativePasteMock.mockReturnValue(false)
})
afterEach(async () => {
@ -192,6 +209,62 @@ describe('usePrimarySelectionPaste', () => {
expect(textarea.value).toBe('alpha beta')
})
it('swallows terminal-armed native paste follow-up even when it has no pending DOM target', async () => {
setUserAgent('Mozilla/5.0 (X11; Linux x86_64)')
shouldSuppressNativePasteMock.mockReturnValue(true)
await renderProbe()
// Stand-in for xterm's helper textarea, which owns its own middle-click
// paste and never registers a pending primary-selection DOM target.
const terminalTextarea = appendXtermHelperTextarea()
let nativeBeforeInput!: Event
const nativePaste = new Event('paste', { bubbles: true, cancelable: true })
await act(async () => {
nativeBeforeInput = dispatchNativePasteBeforeInput(terminalTextarea)
terminalTextarea.dispatchEvent(nativePaste)
await flushPromises()
})
expect(nativeBeforeInput.defaultPrevented).toBe(true)
expect(nativePaste.defaultPrevented).toBe(true)
expect(readPrimarySelectionTextMock).not.toHaveBeenCalled()
})
it('does not suppress an unrelated document paste while the terminal window is armed', async () => {
setUserAgent('Mozilla/5.0 (X11; Linux x86_64)')
// Armed window is active, but the paste targets a control outside the
// terminal (e.g. right-click Paste into a form field) and must survive.
shouldSuppressNativePasteMock.mockReturnValue(true)
await renderProbe()
const unrelatedTextarea = appendTextarea()
let nativeBeforeInput!: Event
const nativePaste = new Event('paste', { bubbles: true, cancelable: true })
await act(async () => {
nativeBeforeInput = dispatchNativePasteBeforeInput(unrelatedTextarea)
unrelatedTextarea.dispatchEvent(nativePaste)
await flushPromises()
})
expect(nativeBeforeInput.defaultPrevented).toBe(false)
expect(nativePaste.defaultPrevented).toBe(false)
})
it('does not suppress native paste when the terminal has not armed the window', async () => {
setUserAgent('Mozilla/5.0 (X11; Linux x86_64)')
shouldSuppressNativePasteMock.mockReturnValue(false)
await renderProbe()
const textarea = appendTextarea()
let nativeBeforeInput!: Event
await act(async () => {
nativeBeforeInput = dispatchNativePasteBeforeInput(textarea)
await flushPromises()
})
expect(nativeBeforeInput.defaultPrevented).toBe(false)
})
it('does not keep middle-click ownership after the gesture window expires', async () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)

View File

@ -3,7 +3,8 @@ import { isLinuxUserAgent, isMacUserAgent } from '@/components/terminal-pane/pan
import {
readPrimarySelectionText,
setPrimarySelectionEnabled,
setPrimarySelectionText
setPrimarySelectionText,
shouldSuppressPrimarySelectionNativePaste
} from '@/lib/primary-selection'
import {
findEditablePrimarySelectionPasteTarget,
@ -40,6 +41,16 @@ function suppressEvent(event: Event): void {
event.stopImmediatePropagation()
}
// Why: the native follow-up paste lands in xterm's hidden helper textarea;
// scope terminal-armed suppression to that surface so unrelated document pastes
// (right-click Paste, keyboard paste into another control) are never swallowed.
function isTerminalNativePasteTarget(target: EventTarget | null): boolean {
if (!(target instanceof Element)) {
return false
}
return target.classList.contains('xterm-helper-textarea') || target.closest('.xterm') !== null
}
function isPrimarySelectionPasteTargetCurrent(
target: EditablePrimarySelectionPasteTarget
): boolean {
@ -84,13 +95,23 @@ export function usePrimarySelectionPaste(enabled: boolean): void {
typeof InputEvent !== 'function' ||
!(event instanceof InputEvent) ||
event.inputType === 'insertFromPaste'
if (!isPasteInputEvent) {
return
}
if (
pendingMiddleTarget &&
Date.now() <= pendingMiddleUntil &&
targetMatchesPending(event.target) &&
isPasteInputEvent
targetMatchesPending(event.target)
) {
suppressEvent(event)
return
}
// Why: the integrated terminal owns its middle-click paste and cannot mark
// a pending DOM target, so honor its armed window to swallow the follow-up
// native paste event that xterm would otherwise forward to the PTY — but
// only for the terminal's own surface, never unrelated document pastes.
if (isTerminalNativePasteTarget(event.target) && shouldSuppressPrimarySelectionNativePaste()) {
suppressEvent(event)
}
}

View File

@ -1,11 +1,13 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
PRIMARY_SELECTION_MAX_LENGTH,
armPrimarySelectionNativePasteSuppression,
getPrimarySelectionText,
readPrimarySelectionText,
resetPrimarySelectionForTests,
setPrimarySelectionEnabled,
setPrimarySelectionText,
shouldSuppressPrimarySelectionNativePaste,
shouldUseSystemPrimarySelectionClipboard
} from './primary-selection'
@ -129,3 +131,51 @@ describe('primary selection buffer', () => {
await expect(readPrimarySelectionText()).resolves.toBe('hello')
})
})
describe('primary-selection native paste suppression', () => {
beforeEach(() => {
resetPrimarySelectionForTests()
vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (X11; Linux x86_64)' })
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('does not arm suppression while primary selection is disabled', () => {
armPrimarySelectionNativePasteSuppression(1_000)
expect(shouldSuppressPrimarySelectionNativePaste(1_000)).toBe(false)
})
it('does not arm suppression off Linux, where there is no native follow-up paste', () => {
vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)' })
setPrimarySelectionEnabled(true)
armPrimarySelectionNativePasteSuppression(1_000)
expect(shouldSuppressPrimarySelectionNativePaste(1_000)).toBe(false)
})
it('suppresses the follow-up native paste within the armed window', () => {
setPrimarySelectionEnabled(true)
armPrimarySelectionNativePasteSuppression(1_000)
expect(shouldSuppressPrimarySelectionNativePaste(1_000)).toBe(true)
expect(shouldSuppressPrimarySelectionNativePaste(1_700)).toBe(true)
})
it('stops suppressing once the armed window elapses', () => {
setPrimarySelectionEnabled(true)
armPrimarySelectionNativePasteSuppression(1_000)
expect(shouldSuppressPrimarySelectionNativePaste(1_800)).toBe(false)
})
it('clears the armed window when primary selection is disabled', () => {
setPrimarySelectionEnabled(true)
armPrimarySelectionNativePasteSuppression(1_000)
setPrimarySelectionEnabled(false)
setPrimarySelectionEnabled(true)
expect(shouldSuppressPrimarySelectionNativePaste(1_000)).toBe(false)
})
})

View File

@ -3,8 +3,11 @@ import type { ReadClipboardTextOptions } from '../../../shared/clipboard-text'
export const PRIMARY_SELECTION_MAX_LENGTH = 65_536
const PRIMARY_SELECTION_MAX_BYTES = PRIMARY_SELECTION_MAX_LENGTH * 4
const PRIMARY_SELECTION_NATIVE_PASTE_SUPPRESSION_MS = 750
let enabled = false
let primarySelectionText = ''
let nativePasteSuppressionUntil = 0
type SelectionClipboardApi = {
readSelectionClipboardText: (options?: ReadClipboardTextOptions) => Promise<string>
@ -45,9 +48,25 @@ export function setPrimarySelectionEnabled(nextEnabled: boolean): void {
enabled = nextEnabled
if (!enabled) {
primarySelectionText = ''
nativePasteSuppressionUntil = 0
}
}
// Why: the integrated terminal injects the primary selection into the PTY
// itself on middle-click, so arm a short window to swallow Chromium's follow-up
// native paste event instead of forwarding text to the PTY twice. Only X11/Linux
// emits that native follow-up; arming elsewhere would swallow legitimate pastes.
export function armPrimarySelectionNativePasteSuppression(now: number = Date.now()): void {
if (!enabled || !isLinuxUserAgent(getUserAgent())) {
return
}
nativePasteSuppressionUntil = now + PRIMARY_SELECTION_NATIVE_PASTE_SUPPRESSION_MS
}
export function shouldSuppressPrimarySelectionNativePaste(now: number = Date.now()): boolean {
return enabled && now <= nativePasteSuppressionUntil
}
export function isPrimarySelectionEnabled(): boolean {
return enabled
}
@ -94,4 +113,5 @@ export async function readPrimarySelectionText(): Promise<string> {
export function resetPrimarySelectionForTests(): void {
enabled = false
primarySelectionText = ''
nativePasteSuppressionUntil = 0
}