From e7fade09759de4dc149dad859c54ca3e3b9a0c25 Mon Sep 17 00:00:00 2001 From: Dominik Menke Date: Tue, 29 Sep 2026 01:20:47 +0200 Subject: [PATCH] Fix middle click paste over text in terminal on Linux xterm.js activates links on mouseup of any mouse button. Since the word link detector turns every word into a link, middle clicking on text activated a link, and the activation handler unconditionally called preventDefault() on the mouseup event. Chromium skips pasting the selection clipboard when mouseup is default prevented, so middle click paste only worked on empty terminal space. Ignore link activations from non-primary buttons before calling preventDefault(). Fixes #157233 --- .../links/browser/terminalLinkManager.ts | 6 ++++++ .../test/browser/terminalLinkManager.test.ts | 19 ++++++++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkManager.ts b/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkManager.ts index 9b0622ddf75e4f..154a8eac830915 100644 --- a/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkManager.ts +++ b/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkManager.ts @@ -200,6 +200,12 @@ export class TerminalLinkManager extends DisposableStore { private _setupLinkDetector(id: string, detector: ITerminalLinkDetector, isExternal: boolean = false): ILinkProvider { const detectorAdapter = this.add(this._instantiationService.createInstance(TerminalLinkDetectorAdapter, detector)); this.add(detectorAdapter.onDidActivateLink(e => { + // xterm.js activates links on mouseup of any button, only the primary button should + // activate. Bail before preventDefault as that would block the middle click paste of + // the selection clipboard on Linux. + if (e.event && e.event.button !== 0) { + return; + } // Prevent default electron link handling so Alt+Click mode works normally e.event?.preventDefault(); // Require correct modifier on click unless event is coming from linkQuickPick selection diff --git a/src/vs/workbench/contrib/terminalContrib/links/test/browser/terminalLinkManager.test.ts b/src/vs/workbench/contrib/terminalContrib/links/test/browser/terminalLinkManager.test.ts index 7132cb0d596618..4a6301a8a5f191 100644 --- a/src/vs/workbench/contrib/terminalContrib/links/test/browser/terminalLinkManager.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/links/test/browser/terminalLinkManager.test.ts @@ -20,7 +20,7 @@ import { IDetectedLinks, TerminalLinkManager } from '../../browser/terminalLinkM import { ITerminalCapabilityImplMap, ITerminalCapabilityStore, TerminalCapability } from '../../../../../../platform/terminal/common/capabilities/capabilities.js'; import { ITerminalConfiguration, ITerminalProcessManager } from '../../../../terminal/common/terminal.js'; import { TestViewDescriptorService } from '../../../../terminal/test/browser/xterm/xtermTerminal.test.js'; -import { TestStorageService } from '../../../../../test/common/workbenchTestServices.js'; +import { TestProductService, TestStorageService } from '../../../../../test/common/workbenchTestServices.js'; import type { ILink, Terminal } from '@xterm/xterm'; import { IXtermCore } from '../../../../terminal/browser/xterm-private.js'; import { TerminalLinkResolver } from '../../browser/terminalLinkResolver.js'; @@ -36,6 +36,7 @@ import { IHoverService } from '../../../../../../platform/hover/browser/hover.js import { ILinkHoverTargetOptions } from '../../../../terminal/browser/widgets/terminalHoverWidget.js'; import { TerminalWidgetManager } from '../../../../terminal/browser/widgets/widgetManager.js'; import { TerminalLink } from '../../browser/terminalLink.js'; +import { IProductService } from '../../../../../../platform/product/common/productService.js'; const defaultTerminalConfig: Partial = { fontFamily: 'monospace', @@ -64,6 +65,9 @@ class TestLinkManager extends TerminalLinkManager { setLinks(links: IDetectedLinks): void { this._links = links; } + getDetectedLinks(y: number, type: 'word' | 'url' | 'localFile'): Promise { + return super._getLinksForType(y, type); + } } suite('TerminalLinkManager', () => { @@ -96,6 +100,7 @@ suite('TerminalLinkManager', () => { instantiationService.stub(IStorageService, store.add(new TestStorageService())); instantiationService.stub(IThemeService, themeService); instantiationService.stub(IViewDescriptorService, viewDescriptorService); + instantiationService.stub(IProductService, TestProductService); const TerminalCtor = (await importAMDNodeModule('@xterm/xterm', 'lib/xterm.js')).Terminal; xterm = store.add(new TerminalCtor({ allowProposedApi: true, cols: 80, rows: 30, logger: TestXtermLogger })); @@ -341,6 +346,18 @@ suite('TerminalLinkManager', () => { strictEqual(fileLink, link2); }); }); + + suite('link activation', () => { + test('should only prevent default for the primary button so middle click paste works', async () => { + await new Promise(r => xterm.write('foo', r)); + const [link] = (await linkManager.getDetectedLinks(1, 'word'))!; + const events = [0, 1, 2].map(button => new MouseEvent('mouseup', { button, cancelable: true })); + for (const e of events) { + link.activate(e, link.text); + } + deepStrictEqual(events.map(e => e.defaultPrevented), [true, false, false]); + }); + }); }); function upcastPartial(v: Partial): T { return v as T;