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:
parent
9ced27eca8
commit
eefded2a04
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue