From 74f75d9ec558b76f3fc68d121ae91bc09feb67c2 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Fri, 14 Aug 2026 09:05:19 -0700 Subject: [PATCH] Fix DOM creation in auxiliary windows (#330777) * Fix DOM creation in auxiliary windows Create workbench DOM nodes in the main JavaScript realm and avoid xterm custom glyph rasterization in auxiliary windows. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * PR feedback --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../textArea/textAreaEditContext.ts | 5 +- .../contrib/suggest/browser/suggestWidget.ts | 2 +- .../test/browser/suggestWidget.test.ts | 102 ++++++++++++++++++ .../aquarium/browser/aquariumOverlay.ts | 25 ++--- .../sessions/contrib/aquarium/browser/fish.ts | 41 ++++--- .../test/browser/aquariumOverlay.test.ts | 72 +++++++++++++ .../speechToText/dictationDownloadRing.ts | 10 +- .../voiceClient/voiceGlowController.ts | 4 +- .../chat/browser/widget/chatClipboard.ts | 3 +- .../chatProgressContentPart.ts | 2 +- .../chat/browser/widget/chatListWidget.ts | 2 +- .../widget/input/chatInputNoticeWidget.ts | 9 +- .../browser/dictationDownloadRing.test.ts | 43 ++++++++ .../browser/voiceClient/voiceGlow.test.ts | 9 +- .../test/browser/widget/chatClipboard.test.ts | 34 +++++- .../input/chatInputNoticeWidget.test.ts | 34 ++++++ .../terminal/browser/xterm/xtermTerminal.ts | 66 ++++++++++-- .../test/browser/xterm/xtermTerminal.test.ts | 85 +++++++++++++++ .../test/browser/xterm/xtermTestUtils.ts | 7 +- 19 files changed, 480 insertions(+), 75 deletions(-) create mode 100644 src/vs/editor/contrib/suggest/test/browser/suggestWidget.test.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/dictationDownloadRing.test.ts diff --git a/src/vs/editor/browser/controller/editContext/textArea/textAreaEditContext.ts b/src/vs/editor/browser/controller/editContext/textArea/textAreaEditContext.ts index bc50d361971..1afbfb12b85 100644 --- a/src/vs/editor/browser/controller/editContext/textArea/textAreaEditContext.ts +++ b/src/vs/editor/browser/controller/editContext/textArea/textAreaEditContext.ts @@ -6,6 +6,7 @@ import './textAreaEditContext.css'; import * as nls from '../../../../../nls.js'; import * as browser from '../../../../../base/browser/browser.js'; +import { $ } from '../../../../../base/browser/dom.js'; import { FastDomNode, createFastDomNode } from '../../../../../base/browser/fastDomNode.js'; import { IKeyboardEvent } from '../../../../../base/browser/keyboardEvent.js'; import * as platform from '../../../../../base/common/platform.js'; @@ -896,12 +897,12 @@ function measureText(targetDocument: Document, text: string, fontInfo: FontInfo, return 0; } - const container = targetDocument.createElement('div'); + const container = $('div'); container.style.position = 'absolute'; container.style.top = '-50000px'; container.style.width = '50000px'; - const regularDomNode = targetDocument.createElement('span'); + const regularDomNode = $('span'); applyFontInfo(regularDomNode, fontInfo); regularDomNode.style.whiteSpace = 'pre'; // just like the textarea regularDomNode.style.tabSize = `${tabSize * fontInfo.spaceWidth}px`; // just like the textarea diff --git a/src/vs/editor/contrib/suggest/browser/suggestWidget.ts b/src/vs/editor/contrib/suggest/browser/suggestWidget.ts index 216fc93e005..b392313610c 100644 --- a/src/vs/editor/contrib/suggest/browser/suggestWidget.ts +++ b/src/vs/editor/contrib/suggest/browser/suggestWidget.ts @@ -952,7 +952,7 @@ export class SuggestWidget implements IDisposable { } if (this._measureContext === undefined) { - this._measureContext = this.element.domNode.ownerDocument.createElement('canvas').getContext('2d'); + this._measureContext = dom.$('canvas').getContext('2d'); } let maxTextWidth: number; diff --git a/src/vs/editor/contrib/suggest/test/browser/suggestWidget.test.ts b/src/vs/editor/contrib/suggest/test/browser/suggestWidget.test.ts new file mode 100644 index 00000000000..723d6db9414 --- /dev/null +++ b/src/vs/editor/contrib/suggest/test/browser/suggestWidget.test.ts @@ -0,0 +1,102 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Event } from '../../../../../base/common/event.js'; +import { toDisposable } from '../../../../../base/common/lifecycle.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IMenu, IMenuService } from '../../../../../platform/actions/common/actions.js'; +import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; +import { IMarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; +import { InMemoryStorageService, IStorageService } from '../../../../../platform/storage/common/storage.js'; +import { CodeEditorWidget } from '../../../../browser/widget/codeEditor/codeEditorWidget.js'; +import { EditorOption } from '../../../../common/config/editorOptions.js'; +import { CompletionItemKind, CompletionItemProvider } from '../../../../common/languages.js'; +import { createCodeEditorServices } from '../../../../test/browser/testCodeEditor.js'; +import { createTextModel } from '../../../../test/common/testTextModel.js'; +import { CompletionModel } from '../../browser/completionModel.js'; +import { CompletionItem } from '../../browser/suggest.js'; +import { SuggestWidget } from '../../browser/suggestWidget.js'; +import { WordDistance } from '../../browser/wordDistance.js'; + +suite('SuggestWidget', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('measures suggestions in an auxiliary window', () => { + const iframe = document.createElement('iframe'); + document.body.appendChild(iframe); + store.add(toDisposable(() => iframe.remove())); + + const auxiliaryDocument = iframe.contentDocument!; + const container = document.createElement('div'); + container.style.width = '500px'; + container.style.height = '300px'; + auxiliaryDocument.body.appendChild(container); + + const createElement = auxiliaryDocument.createElement; + auxiliaryDocument.createElement = () => { + throw new Error('Not allowed to create elements in child window JavaScript context.'); + }; + store.add(toDisposable(() => auxiliaryDocument.createElement = createElement)); + + const services = new ServiceCollection( + [IStorageService, store.add(new InMemoryStorageService())], + [IMarkdownRendererService, new class extends mock() { }], + [IMenuService, new class extends mock() { + override createMenu(): IMenu { + return new class extends mock() { + override readonly onDidChange = Event.None; + override dispose(): void { } + }; + } + }], + ); + const instantiationService = createCodeEditorServices(store, services); + const editor = store.add(instantiationService.createInstance( + CodeEditorWidget, + container, + { suggest: { fitWidthToDetails: true } }, + { contributions: [] }, + )); + const textModel = store.add(createTextModel('a')); + editor.setModel(textModel); + editor.layout({ width: 500, height: 300 }); + + const position = { lineNumber: 1, column: 2 }; + const completion = { + label: { label: 'agent', detail: ' with a detailed description' }, + insertText: 'agent', + kind: CompletionItemKind.Function, + range: { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 2 }, + }; + const completionList = { suggestions: [completion] }; + const provider: CompletionItemProvider = { + _debugDisplayName: 'test', + provideCompletionItems: () => completionList, + }; + const completionModel = new CompletionModel( + [new CompletionItem(position, completion, completionList, provider)], + position.column, + { leadingLineContent: 'a', characterCountDelta: 0 }, + WordDistance.None, + editor.getOption(EditorOption.suggest), + editor.getOption(EditorOption.snippetSuggestions), + ); + const widget = store.add(instantiationService.createInstance(SuggestWidget, editor)); + + widget.showSuggestions(completionModel, 0, false, false, false); + + assert.deepStrictEqual({ + ownerDocument: widget.element.domNode.ownerDocument === auxiliaryDocument, + mainRealmElement: widget.element.domNode instanceof HTMLElement, + attached: auxiliaryDocument.body.contains(widget.element.domNode), + }, { + ownerDocument: true, + mainRealmElement: true, + attached: true, + }); + }); +}); diff --git a/src/vs/sessions/contrib/aquarium/browser/aquariumOverlay.ts b/src/vs/sessions/contrib/aquarium/browser/aquariumOverlay.ts index c2a7df3b28c..08c7fe457f2 100644 --- a/src/vs/sessions/contrib/aquarium/browser/aquariumOverlay.ts +++ b/src/vs/sessions/contrib/aquarium/browser/aquariumOverlay.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { addDisposableGenericMouseDownListener, addDisposableGenericMouseMoveListener, addDisposableListener, EventType, getWindow, scheduleAtNextAnimationFrame } from '../../../../base/browser/dom.js'; +import { $, addDisposableGenericMouseDownListener, addDisposableGenericMouseMoveListener, addDisposableListener, EventType, getWindow, scheduleAtNextAnimationFrame } from '../../../../base/browser/dom.js'; import { createInstantHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { RunOnceScheduler } from '../../../../base/common/async.js'; import { Codicon } from '../../../../base/common/codicons.js'; @@ -167,9 +167,7 @@ export class AquariumService extends Disposable implements IAquariumService { } mountToggle(parent: HTMLElement): IMountedToggleHandle { - const doc = parent.ownerDocument; - const button = doc.createElement('button'); - button.className = 'agents-aquarium-toggle'; + const button = $('button.agents-aquarium-toggle'); button.type = 'button'; this.updateToggleButtonVisual(button, !!this.activeRef.value); @@ -304,7 +302,7 @@ export class AquariumService extends Disposable implements IAquariumService { // Build the icon as a real DOM child instead of innerHTML to satisfy Trusted Types. button.replaceChildren(); - const iconSpan = button.ownerDocument.createElement('span'); + const iconSpan = $('span'); // The icon is purely decorative; the button already has an aria-label. iconSpan.setAttribute('aria-hidden', 'true'); addIconClasses(iconSpan, icon); @@ -318,11 +316,11 @@ export class AquariumService extends Disposable implements IAquariumService { const showStreak = streak > 0 || revivable > 0; button.classList.toggle('has-streak', showStreak); if (showStreak) { - const streakSpan = button.ownerDocument.createElement('span'); + const streakSpan = $('span'); streakSpan.className = 'agents-aquarium-toggle-streak'; streakSpan.setAttribute('aria-hidden', 'true'); if (active) { - const hungerIconSpan = button.ownerDocument.createElement('span'); + const hungerIconSpan = $('span'); addIconClasses(hungerIconSpan, hungerIcon); streakSpan.appendChild(hungerIconSpan); } @@ -508,9 +506,7 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe } const store = new DisposableStore(); - const doc = targetWindow.document; - const water = doc.createElement('div'); - water.className = 'agents-aquarium-water'; + const water = $('.agents-aquarium-water'); // Decorative: hide the entire subtree from a11y tree. water.setAttribute('aria-hidden', 'true'); // First child so subsequent chat bar content paints over it. @@ -524,12 +520,10 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe sessionsContainer.classList.remove('aquarium-active'); })); - const fishLayer = doc.createElement('div'); - fishLayer.className = 'agents-aquarium-fish-layer'; + const fishLayer = $('.agents-aquarium-fish-layer'); water.appendChild(fishLayer); - const foodLayer = doc.createElement('div'); - foodLayer.className = 'agents-aquarium-food-layer'; + const foodLayer = $('.agents-aquarium-food-layer'); water.appendChild(foodLayer); const bounds = { width: 0, height: 0 }; @@ -675,8 +669,7 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe const oldest = food[0]; removeFood(oldest); } - const el = doc.createElement('div'); - el.className = 'agents-aquarium-food'; + const el = $('.agents-aquarium-food'); el.style.transform = `translate(${dropX}px, ${dropY}px)`; foodLayer.appendChild(el); food.push({ element: el, positionX: dropX, positionY: dropY, fallSpeed: randomBetween(20, 35) }); diff --git a/src/vs/sessions/contrib/aquarium/browser/fish.ts b/src/vs/sessions/contrib/aquarium/browser/fish.ts index 03bf01b5b02..74b4b5c6989 100644 --- a/src/vs/sessions/contrib/aquarium/browser/fish.ts +++ b/src/vs/sessions/contrib/aquarium/browser/fish.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { $ } from '../../../../base/browser/dom.js'; import { VSCODE_LOGO_PATH } from './vscodeLogoPath.js'; /** @@ -98,16 +99,14 @@ export class Fish { this.size = opts.size; this.wanderAngle = Math.atan2(opts.velocityY, opts.velocityX); - this.element = targetDocument.createElement('div'); - this.element.className = 'agents-aquarium-fish'; + this.element = $('.agents-aquarium-fish'); this.element.style.width = `${opts.size}px`; this.element.style.height = `${opts.size}px`; this.element.style.color = SPECIES_COLOR[opts.species]; // Inner element receives the directional flip so the body strip animations // (driven by --agents-aquarium-strip-index) are unaffected by direction changes. - this.innerElement = targetDocument.createElement('div'); - this.innerElement.className = 'agents-aquarium-fish-inner'; + this.innerElement = $('.agents-aquarium-fish-inner'); this.innerElement.appendChild(buildFishSvg(targetDocument)); this.element.appendChild(this.innerElement); @@ -171,8 +170,6 @@ export class Fish { } } -const SVG_NS = 'http://www.w3.org/2000/svg'; - /** * Number of vertical strips the body is sliced into. More strips = smoother * wave, but each strip is one `` node and one CSS animation per fish. @@ -204,8 +201,8 @@ function ensureSharedDefs(targetDocument: Document): void { return; } const stripWidth = (BODY_X_END - BODY_X_START) / NUM_BODY_STRIPS; - const container = targetDocument.createElementNS(SVG_NS, 'svg'); - container.setAttribute('xmlns', SVG_NS); + const container = $.SVG('svg'); + container.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); container.setAttribute('width', '0'); container.setAttribute('height', '0'); container.setAttribute('aria-hidden', 'true'); @@ -217,14 +214,14 @@ function ensureSharedDefs(targetDocument: Document): void { // All strips reference this symbol via ``, // so the path data is parsed exactly ONCE per session instead of FISH_COUNT * NUM_STRIPS. - container.appendChild(createVSCodeLogoSymbol(targetDocument)); + container.appendChild(createVSCodeLogoSymbol()); - const defs = targetDocument.createElementNS(SVG_NS, 'defs'); + const defs = $.SVG('defs'); for (let i = 0; i < NUM_BODY_STRIPS; i++) { - const clip = targetDocument.createElementNS(SVG_NS, 'clipPath'); + const clip = $.SVG('clipPath'); clip.setAttribute('id', `agents-aquarium-fish-clip-${i}`); clip.setAttribute('clipPathUnits', 'userSpaceOnUse'); - const rect = targetDocument.createElementNS(SVG_NS, 'rect'); + const rect = $.SVG('rect'); rect.setAttribute('x', String(BODY_X_START + i * stripWidth)); rect.setAttribute('y', '-20'); // Larger overlap (0.8 user-units) hides seams when adjacent strips @@ -239,13 +236,13 @@ function ensureSharedDefs(targetDocument: Document): void { sharedDefsByDocument.set(targetDocument, container); } -function createVSCodeLogoSymbol(targetDocument: Document): SVGSymbolElement { - const symbol = targetDocument.createElementNS(SVG_NS, 'symbol'); +function createVSCodeLogoSymbol(): SVGSymbolElement { + const symbol = $.SVG('symbol'); symbol.setAttribute('id', SHARED_LOGO_SYMBOL_ID); symbol.setAttribute('viewBox', '0 0 96 96'); symbol.setAttribute('overflow', 'visible'); - const logoPath = targetDocument.createElementNS(SVG_NS, 'path'); + const logoPath = $.SVG('path'); logoPath.setAttribute('d', VSCODE_LOGO_PATH); logoPath.setAttribute('fill', 'currentColor'); logoPath.setAttribute('fill-rule', 'evenodd'); @@ -259,8 +256,8 @@ function createVSCodeLogoSymbol(targetDocument: Document): SVGSymbolElement { * - VS Code logo body, sliced into N vertical strips that each oscillate in * Y with a phase-offset CSS animation (the "swimming" sine wave) * - * Colors come from `currentColor` on the parent element. Built with - * `document.createElementNS` (no innerHTML) to satisfy Trusted Types. + * Colors come from `currentColor` on the parent element. Built without + * `innerHTML` to satisfy Trusted Types. * * The strip clipPath defs and the logo symbol are shared across all fish via * {@link ensureSharedDefs}. @@ -268,8 +265,8 @@ function createVSCodeLogoSymbol(targetDocument: Document): SVGSymbolElement { function buildFishSvg(targetDocument: Document): SVGSVGElement { ensureSharedDefs(targetDocument); - const svg = targetDocument.createElementNS(SVG_NS, 'svg'); - svg.setAttribute('xmlns', SVG_NS); + const svg = $.SVG('svg'); + svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); svg.setAttribute('focusable', 'false'); // viewBox 0..96 matches the original VS Code icon. svg.setAttribute('viewBox', '0 0 96 96'); @@ -281,13 +278,13 @@ function buildFishSvg(targetDocument: Document): SVGSVGElement { // Body: NUM_BODY_STRIPS overlapping references to the shared logo symbol, // each clipped to its vertical band via shared clipPath defs. Each strip // animates translateY with a phase offset driven by --agents-aquarium-strip-index. - const bodyGroup = targetDocument.createElementNS(SVG_NS, 'g'); + const bodyGroup = $.SVG('g'); bodyGroup.setAttribute('class', 'agents-aquarium-fish-body'); for (let i = 0; i < NUM_BODY_STRIPS; i++) { - const stripG = targetDocument.createElementNS(SVG_NS, 'g'); + const stripG = $.SVG('g'); stripG.setAttribute('class', 'agents-aquarium-fish-strip'); stripG.style.setProperty('--agents-aquarium-strip-index', String(i)); - const stripUse = targetDocument.createElementNS(SVG_NS, 'use'); + const stripUse = $.SVG('use'); stripUse.setAttribute('href', `#${SHARED_LOGO_SYMBOL_ID}`); stripUse.setAttribute('clip-path', `url(#agents-aquarium-fish-clip-${i})`); stripG.appendChild(stripUse); diff --git a/src/vs/sessions/contrib/aquarium/test/browser/aquariumOverlay.test.ts b/src/vs/sessions/contrib/aquarium/test/browser/aquariumOverlay.test.ts index 0758a4eb565..4cb0532fd73 100644 --- a/src/vs/sessions/contrib/aquarium/test/browser/aquariumOverlay.test.ts +++ b/src/vs/sessions/contrib/aquarium/test/browser/aquariumOverlay.test.ts @@ -16,6 +16,7 @@ import { InMemoryStorageService, StorageScope } from '../../../../../platform/st import { NullTelemetryServiceShape } from '../../../../../platform/telemetry/common/telemetryUtils.js'; import { IWorkbenchLayoutService } from '../../../../../workbench/services/layout/browser/layoutService.js'; import { AquariumService, SESSIONS_DEVELOPER_JOY_ENABLED_SETTING } from '../../browser/aquariumOverlay.js'; +import { disposeSharedFishDefs, Fish, FishSpecies } from '../../browser/fish.js'; suite('AquariumService', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -87,4 +88,75 @@ suite('AquariumService', () => { afterShow: { visible: true, display: '' }, }); }); + + test('creates aquarium elements in the main realm for an auxiliary window', () => { + const iframe = document.createElement('iframe'); + document.body.appendChild(iframe); + store.add(toDisposable(() => iframe.remove())); + + const auxiliaryDocument = iframe.contentDocument!; + const toggleContainer = document.createElement('div'); + auxiliaryDocument.body.appendChild(toggleContainer); + const createElement = auxiliaryDocument.createElement; + auxiliaryDocument.createElement = () => { + throw new Error('Not allowed to create elements in child window JavaScript context.'); + }; + store.add(toDisposable(() => auxiliaryDocument.createElement = createElement)); + + const storageService = store.add(new InMemoryStorageService()); + const layoutService = new class extends mock() { + override readonly mainContainer = document.createElement('div'); + }(); + const hoverService = new class extends mock() { + override setupManagedHover(): IManagedHover { + return { + dispose() { }, + show() { }, + hide() { }, + update() { }, + }; + } + }(); + const configurationService = new TestConfigurationService({ [SESSIONS_DEVELOPER_JOY_ENABLED_SETTING]: true }); + store.add(configurationService.onDidChangeConfigurationEmitter); + const service = store.add(new AquariumService( + layoutService, + new MockContextKeyService(), + hoverService, + storageService, + configurationService, + new TestAccessibilityService(), + new NullTelemetryServiceShape(), + )); + store.add(service.mountToggle(toggleContainer)); + const fish = new Fish({ + species: FishSpecies.Stable, + size: 24, + positionX: 0, + positionY: 0, + velocityX: 1, + velocityY: 0, + }, auxiliaryDocument); + auxiliaryDocument.body.appendChild(fish.element); + store.add(toDisposable(() => { + fish.element.remove(); + disposeSharedFishDefs(auxiliaryDocument); + })); + + const button = toggleContainer.querySelector('.agents-aquarium-toggle'); + const svg = fish.element.querySelector('svg'); + assert.deepStrictEqual({ + buttonOwnerDocument: button?.ownerDocument === auxiliaryDocument, + fishOwnerDocument: fish.element.ownerDocument === auxiliaryDocument, + mainRealmButton: button instanceof HTMLButtonElement, + mainRealmFish: fish.element instanceof HTMLDivElement, + mainRealmSvg: svg instanceof SVGSVGElement, + }, { + buttonOwnerDocument: true, + fishOwnerDocument: true, + mainRealmButton: true, + mainRealmFish: true, + mainRealmSvg: true, + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/browser/speechToText/dictationDownloadRing.ts b/src/vs/workbench/contrib/chat/browser/speechToText/dictationDownloadRing.ts index e049082dc23..b10477f5766 100644 --- a/src/vs/workbench/contrib/chat/browser/speechToText/dictationDownloadRing.ts +++ b/src/vs/workbench/contrib/chat/browser/speechToText/dictationDownloadRing.ts @@ -3,14 +3,13 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { $ } from '../../../../../base/browser/dom.js'; import { IManagedHoverContent } from '../../../../../base/browser/ui/hover/hover.js'; import { IMarkdownString, MarkdownString } from '../../../../../base/common/htmlContent.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; import { localize } from '../../../../../nls.js'; import { IChatSpeechToTextService } from './chatSpeechToTextService.js'; -const SVG_NS = 'http://www.w3.org/2000/svg'; - /** Radius of the progress ring in the 16×16 viewBox used for the toolbar icon. */ const RING_RADIUS = 7; const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS; @@ -30,19 +29,18 @@ export class DictationDownloadRing extends Disposable { ) { super(); - const ownerDocument = container.ownerDocument; - const svg = ownerDocument.createElementNS(SVG_NS, 'svg') as SVGSVGElement; + const svg = $.SVG('svg'); svg.classList.add('dictation-download-ring'); svg.setAttribute('viewBox', '0 0 16 16'); svg.setAttribute('aria-hidden', 'true'); - const track = ownerDocument.createElementNS(SVG_NS, 'circle') as SVGCircleElement; + const track = $.SVG('circle'); track.classList.add('dictation-download-ring-track'); track.setAttribute('cx', '8'); track.setAttribute('cy', '8'); track.setAttribute('r', String(RING_RADIUS)); - const progress = ownerDocument.createElementNS(SVG_NS, 'circle') as SVGCircleElement; + const progress = $.SVG('circle'); progress.classList.add('dictation-download-ring-progress'); progress.setAttribute('cx', '8'); progress.setAttribute('cy', '8'); diff --git a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceGlowController.ts b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceGlowController.ts index a93934ad9de..ce3188060cb 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceGlowController.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceGlowController.ts @@ -278,13 +278,11 @@ const RIM_SIZE_FLOOR = 0.35; */ export function createVoiceRimLight(target: HTMLElement, accent: Color, theme: GlowThemeKind, mood: VoiceRimMood = 'cool', background?: Color): IVoiceRimLight { const store = new DisposableStore(); - const doc = target.ownerDocument; if (!target.style.position) { target.style.position = 'relative'; } - const slot = doc.createElement('div'); - slot.className = 'voice-glow-slot voice-glow-slot-inline'; + const slot = $('.voice-glow-slot.voice-glow-slot-inline'); target.appendChild(slot); store.add(toDisposable(() => slot.remove())); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatClipboard.ts b/src/vs/workbench/contrib/chat/browser/widget/chatClipboard.ts index 0456d9a0c84..0a84ece6cd0 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatClipboard.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatClipboard.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { $ } from '../../../../../base/browser/dom.js'; import { appendEscapedMarkdownInlineCode, isPortableLinkTarget, isPortableMarkdownTarget } from '../../../../../base/common/htmlContent.js'; import * as marked from '../../../../../base/common/marked/marked.js'; import { IMarkdownEdit, markdownTokensToPlainText, rewriteMarkdownLinks } from '../../../../../base/common/markdownLinks.js'; @@ -22,7 +23,7 @@ function replaceWithLabel(element: Element, label: string): void { return; } - const code = element.ownerDocument.createElement('code'); + const code = $('code'); code.textContent = label; element.replaceWith(code); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatProgressContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatProgressContentPart.ts index ec5945d279b..33caddfbbfa 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatProgressContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatProgressContentPart.ts @@ -137,7 +137,7 @@ export class ChatProgressContentPart extends Disposable implements IChatContentP const shimmerText = text.slice(0, remaining); const suffixText = text.slice(remaining); - const span = element.ownerDocument.createElement('span'); + const span = $('span'); span.classList.add('chat-progress-shimmer-text'); span.textContent = shimmerText; node.parentNode?.insertBefore(span, node); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts index 74a0009d60b..650ce1078f9 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts @@ -681,7 +681,7 @@ export class ChatListWidget extends Disposable { return; } - const holder = this._container.ownerDocument.createElement('div'); + const holder = dom.$('div'); for (const fragment of fragments) { holder.appendChild(fragment); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputNoticeWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputNoticeWidget.ts index c086c0f8d6a..960690385a1 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputNoticeWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputNoticeWidget.ts @@ -3,9 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { addDisposableListener, EventType, isAncestorOfActiveElement, setVisibility } from '../../../../../../base/browser/dom.js'; +import { $, addDisposableListener, EventType, isAncestorOfActiveElement, setVisibility } from '../../../../../../base/browser/dom.js'; import { ActionBar } from '../../../../../../base/browser/ui/actionbar/actionbar.js'; -import { mainWindow } from '../../../../../../base/browser/window.js'; import { alert, status } from '../../../../../../base/browser/ui/aria/aria.js'; import { StandardKeyboardEvent } from '../../../../../../base/browser/keyboardEvent.js'; import { Action } from '../../../../../../base/common/actions.js'; @@ -102,9 +101,7 @@ export class ChatInputNoticeWidget extends Disposable implements IChatInputNotic this._variant = options.variant; this._ariaRoleDescription = options.ariaRoleDescription; - // Detached notices are created in the main window's document, the same as - // `dom.$` does, and adopted when their owner parents them. - this.domNode = (options.container?.ownerDocument ?? mainWindow.document).createElement('div'); + this.domNode = $('div'); this.domNode.classList.add('chat-input-notice', `chat-input-notice-${options.variant}`); if (options.className) { this.domNode.classList.add(options.className); @@ -216,7 +213,7 @@ export class ChatInputNoticeWidget extends Disposable implements IChatInputNotic addAction(options: IChatInputNoticeActionOptions): HTMLElement { const register = (disposable: T): T => options.store ? options.store.add(disposable) : this._register(disposable); - const container = this.domNode.ownerDocument.createElement('div'); + const container = $('div'); container.classList.add('chat-input-notice-action'); (options.parent ?? this.domNode).appendChild(container); register(toDisposable(() => container.remove())); diff --git a/src/vs/workbench/contrib/chat/test/browser/dictationDownloadRing.test.ts b/src/vs/workbench/contrib/chat/test/browser/dictationDownloadRing.test.ts new file mode 100644 index 00000000000..5f91c78e305 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/dictationDownloadRing.test.ts @@ -0,0 +1,43 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Event } from '../../../../../base/common/event.js'; +import { toDisposable } from '../../../../../base/common/lifecycle.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IChatSpeechToTextService } from '../../browser/speechToText/chatSpeechToTextService.js'; +import { DictationDownloadRing } from '../../browser/speechToText/dictationDownloadRing.js'; + +suite('DictationDownloadRing', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('creates its SVG elements in the main realm for an auxiliary window', () => { + const iframe = document.createElement('iframe'); + document.body.appendChild(iframe); + store.add(toDisposable(() => iframe.remove())); + + const auxiliaryDocument = iframe.contentDocument!; + const container = document.createElement('div'); + auxiliaryDocument.body.appendChild(container); + const service = new class extends mock() { + override readonly onDidChangeModelDownloadProgress = Event.None; + override readonly modelDownloadProgress = 0.5; + }; + + store.add(new DictationDownloadRing(container, service)); + const ring = container.querySelector('svg'); + + assert.deepStrictEqual({ + ownerDocument: ring?.ownerDocument === auxiliaryDocument, + mainRealmSvg: ring instanceof SVGSVGElement, + circles: ring?.querySelectorAll('circle').length, + }, { + ownerDocument: true, + mainRealmSvg: true, + circles: 2, + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceGlow.test.ts b/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceGlow.test.ts index 527705d5611..76c74fe4427 100644 --- a/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceGlow.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceGlow.test.ts @@ -12,7 +12,7 @@ import { IColorTheme } from '../../../../../../platform/theme/common/themeServic import { chatDictationActiveMicGlow, chatVoiceGlowBaseColor, chatVoiceSpeakingGlow } from '../../../common/widget/chatColors.js'; import { resolveDictationMicAccent } from '../../../browser/speechToText/dictationMicGlow.js'; import { isGlowingVoiceState, GlowThemeKind, resolveVoiceGlowColors, resolveVoiceRimAccent, shouldRenderVoiceInputGlow, VOICE_GLOW_SPEAKING_HUE_SHIFT } from '../../../browser/voiceClient/voiceGlow.js'; -import { createVoiceGlowController } from '../../../browser/voiceClient/voiceGlowController.js'; +import { createVoiceGlowController, createVoiceRimLight } from '../../../browser/voiceClient/voiceGlowController.js'; suite('VoiceGlow', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -51,17 +51,20 @@ suite('VoiceGlow', () => { const controller = disposables.add(createVoiceGlowController(target)); controller.render('listening', 0.5, false); + disposables.add(createVoiceRimLight(target, Color.fromHex('#58A6FF'), 'dark')); assert.deepStrictEqual({ active: target.classList.contains('voice-active'), listening: target.classList.contains('voice-listening'), slots: target.querySelectorAll('.voice-glow-slot').length, + inlineSlots: target.querySelectorAll('.voice-glow-slot-inline').length, layers: target.querySelectorAll('.voice-glow-rim-corners, .voice-glow-rim-bloom').length, }, { active: true, listening: true, - slots: 2, - layers: 2, + slots: 3, + inlineSlots: 1, + layers: 4, }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatClipboard.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatClipboard.test.ts index f85573842bc..87c1e75aeb8 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatClipboard.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatClipboard.test.ts @@ -5,6 +5,7 @@ import assert from 'assert'; import { convertHtmlToMarkdown } from '../../../../../../base/browser/htmlToMarkdown.js'; +import { toDisposable } from '../../../../../../base/common/lifecycle.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { sanitizeChatClipboardFragment, toPortableMarkdown } from '../../../browser/widget/chatClipboard.js'; @@ -23,7 +24,7 @@ function sanitizeToHtml(html: string): string { } suite('ChatClipboard', () => { - ensureNoDisposablesAreLeakedInTestSuite(); + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); test('reports whether the selection had to change', () => { assert.deepStrictEqual( @@ -72,6 +73,37 @@ suite('ChatClipboard', () => { '

before after

'); }); + test('sanitizes a fragment from an auxiliary window', () => { + const iframe = document.createElement('iframe'); + document.body.appendChild(iframe); + disposables.add(toDisposable(() => iframe.remove())); + + const auxiliaryDocument = iframe.contentDocument!; + const fragment = auxiliaryDocument.createDocumentFragment(); + const anchor = auxiliaryDocument.createElement('a'); + anchor.setAttribute('data-href', 'file:///repo/a.ts'); + anchor.textContent = 'a.ts'; + fragment.appendChild(anchor); + const createElement = auxiliaryDocument.createElement; + auxiliaryDocument.createElement = () => { + throw new Error('Not allowed to create elements in child window JavaScript context.'); + }; + disposables.add(toDisposable(() => auxiliaryDocument.createElement = createElement)); + + sanitizeChatClipboardFragment(fragment); + const replacement = fragment.firstElementChild; + + assert.deepStrictEqual({ + html: replacement?.outerHTML, + ownerDocument: replacement?.ownerDocument === auxiliaryDocument, + mainRealmElement: replacement instanceof HTMLElement, + }, { + html: 'a.ts', + ownerDocument: true, + mainRealmElement: true, + }); + }); + test('produces markdown without internal targets when pasted back into chat', () => { const copied = sanitizeToHtml( '

This is inherited from the FooBar class. ' diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputNoticeWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputNoticeWidget.test.ts index dcae937c25e..c01f44371ea 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputNoticeWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputNoticeWidget.test.ts @@ -63,6 +63,40 @@ suite('ChatInputNoticeWidget', () => { { parented: false, connected: false }); }); + test('creates the notice and its actions for an auxiliary window', () => { + const iframe = document.createElement('iframe'); + document.body.appendChild(iframe); + disposables.add(toDisposable(() => iframe.remove())); + + const auxiliaryDocument = iframe.contentDocument!; + const container = document.createElement('div'); + auxiliaryDocument.body.appendChild(container); + const createElement = auxiliaryDocument.createElement; + auxiliaryDocument.createElement = () => { + throw new Error('Not allowed to create elements in child window JavaScript context.'); + }; + disposables.add(toDisposable(() => auxiliaryDocument.createElement = createElement)); + + const notice = createNotice(container); + const action = notice.addAction({ + ariaLabel: 'Continue', + icon: Codicon.check, + onActivate: () => { }, + }); + + assert.deepStrictEqual({ + noticeOwnerDocument: notice.domNode.ownerDocument === auxiliaryDocument, + actionOwnerDocument: action.ownerDocument === auxiliaryDocument, + mainRealmNotice: notice.domNode instanceof HTMLElement, + mainRealmAction: action instanceof HTMLElement, + }, { + noticeOwnerDocument: true, + actionOwnerDocument: true, + mainRealmNotice: true, + mainRealmAction: true, + }); + }); + test('interrupts for an introduction, but waits its turn for a tip', () => { const container = createContainer(disposables); const ariaContainer = dom.append(container, dom.$('div')); diff --git a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts index 089d87c9986..b86678dc8de 100644 --- a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts @@ -121,6 +121,7 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach private readonly _xtermColorProvider: IXtermColorProvider; private readonly _capabilities: ITerminalCapabilityStore; private readonly _disableOverviewRuler: boolean; + private readonly _mainDocument: Document; private static _suggestedRendererType: 'dom' | undefined = undefined; private _attached?: { container: HTMLElement; options: IXtermAttachToElementOptions }; @@ -144,7 +145,10 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach private _searchAddon?: SearchAddonType; private _unicode11Addon?: Unicode11AddonType; private _webglAddon?: WebglAddonType; - private _webglAddonCustomGlyphs?: boolean = false; + private readonly _webglContextLossListener = this._register(new MutableDisposable()); + private _webglAddonCustomGlyphs?: boolean; + private _webglAddonLoading = false; + private _webglAddonLoadId = 0; private _serializeAddon?: SerializeAddonType; private _imageAddon?: ImageAddonType; private readonly _ligaturesAddon: MutableDisposable = this._register(new MutableDisposable()); @@ -228,6 +232,7 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach this._xtermColorProvider = options.xtermColorProvider; this._capabilities = options.capabilities; this._disableOverviewRuler = options.disableOverviewRuler ?? false; + this._mainDocument = layoutService.mainContainer.ownerDocument; const font = this._terminalConfigurationService.getFont(dom.getActiveWindow(), undefined, true); const config = this._terminalConfigurationService.config; @@ -237,7 +242,7 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach allowProposedApi: true, cols: options.cols, rows: options.rows, - documentOverride: layoutService.mainContainer.ownerDocument, + documentOverride: this._mainDocument, altClickMovesCursor: config.altClickMovesCursor && editorOptions.multiCursorModifier === 'alt', scrollback: config.scrollback, theme: this.getXtermTheme(), @@ -882,26 +887,58 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach private async _enableWebglRenderer(): Promise { // Currently webgl options can only be specified on addon creation - if (!this.raw.element || this._webglAddon && this._webglAddonCustomGlyphs === this._terminalConfigurationService.config.customGlyphs) { + if (!this.raw.element) { + return; + } + const customGlyphs = this._getWebglCustomGlyphs(); + if ((this._webglAddon || this._webglAddonLoading) && this._webglAddonCustomGlyphs === customGlyphs) { return; } // Dispose of existing addon before creating a new one to avoid leaking WebGL contexts this._disposeOfWebglRenderer(); - this._webglAddonCustomGlyphs = this._terminalConfigurationService.config.customGlyphs; + const loadId = this._webglAddonLoadId; + this._webglAddonLoading = true; + this._webglAddonCustomGlyphs = customGlyphs; + + let Addon: typeof WebglAddonType; + try { + Addon = await this._xtermAddonLoader.importAddon('webgl'); + } catch (error) { + if (loadId === this._webglAddonLoadId) { + this._webglAddonLoading = false; + this._webglAddonCustomGlyphs = undefined; + } + throw error; + } + if (loadId !== this._webglAddonLoadId) { + return; + } + + this._webglAddonLoading = false; + if (!this.raw.element) { + this._webglAddonCustomGlyphs = undefined; + return; + } + + const currentCustomGlyphs = this._getWebglCustomGlyphs(); + if (customGlyphs !== currentCustomGlyphs) { + this._webglAddonCustomGlyphs = undefined; + await this._enableWebglRenderer(); + return; + } - const Addon = await this._xtermAddonLoader.importAddon('webgl'); this._webglAddon = new Addon({ - customGlyphs: this._terminalConfigurationService.config.customGlyphs + customGlyphs }); try { this.raw.loadAddon(this._webglAddon); this._logService.trace('Webgl was loaded'); - this._store.add(this._webglAddon.onContextLoss(() => { + this._webglContextLossListener.value = this._webglAddon.onContextLoss(() => { this._logService.info(`Webgl lost context, disposing of webgl renderer`); this._disposeOfWebglRenderer(); - })); + }); this._refreshImageAddon(); // WebGL renderer cell dimensions differ from the DOM renderer, make sure the terminal // gets resized after the webgl addon is loaded @@ -919,6 +956,11 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach } } + private _getWebglCustomGlyphs(): boolean { + // The custom glyph rasterizer creates a canvas through the rendering document, which is blocked in auxiliary windows. + return this._terminalConfigurationService.config.customGlyphs && this.raw.element?.ownerDocument === this._mainDocument; + } + @debounce(100) private async _refreshLigaturesAddon(): Promise { if (!this.raw.element) { @@ -994,6 +1036,10 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach } private _disposeOfWebglRenderer(): void { + this._webglAddonLoadId++; + this._webglAddonLoading = false; + this._webglAddonCustomGlyphs = undefined; + this._webglContextLossListener.clear(); if (!this._webglAddon) { return; } @@ -1003,7 +1049,6 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach // ignore } this._webglAddon = undefined; - this._webglAddonCustomGlyphs = undefined; this._refreshImageAddon(); // WebGL renderer cell dimensions differ from the DOM renderer, make sure the terminal // gets resized after the webgl addon is disposed @@ -1100,6 +1145,9 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach refresh() { this._updateTheme(); this._decorationAddon.refreshLayouts(); + if (this._webglAddon || this._webglAddonLoading) { + this._enableWebglRenderer(); + } } private async _updateUnicodeVersion(): Promise { diff --git a/src/vs/workbench/contrib/terminal/test/browser/xterm/xtermTerminal.test.ts b/src/vs/workbench/contrib/terminal/test/browser/xterm/xtermTerminal.test.ts index 245e65b33db..5294263f9a6 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/xterm/xtermTerminal.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/xterm/xtermTerminal.test.ts @@ -6,11 +6,15 @@ import type { Terminal } from '@xterm/xterm'; import { deepStrictEqual, ok, strictEqual } from 'assert'; import { importAMDNodeModule } from '../../../../../../amdX.js'; +import { timeout } from '../../../../../../base/common/async.js'; import { Color, RGBA } from '../../../../../../base/common/color.js'; import { Emitter } from '../../../../../../base/common/event.js'; +import { toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { IEditorOptions } from '../../../../../../editor/common/config/editorOptions.js'; import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { IConfigurationChangeEvent } from '../../../../../../platform/configuration/common/configuration.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { TerminalCapabilityStore } from '../../../../../../platform/terminal/common/capabilities/terminalCapabilityStore.js'; import { IThemeService } from '../../../../../../platform/theme/common/themeService.js'; @@ -102,6 +106,7 @@ suite('XtermTerminal', () => { TestWebglAddon.shouldThrow = false; TestWebglAddon.isEnabled = false; + TestWebglAddon.customGlyphOptions.length = 0; }); test('should use fallback dimensions of 80x30', () => { @@ -109,6 +114,86 @@ suite('XtermTerminal', () => { strictEqual(xterm.raw.rows, 30); }); + test('disables custom glyphs when moved into an auxiliary window', async () => { + await configurationService.setUserConfiguration('terminal.integrated', { + ...defaultTerminalConfig, + gpuAcceleration: 'on', + customGlyphs: true, + }); + configurationService.onDidChangeConfigurationEmitter.fire(new class extends mock() { + override affectsConfiguration(section: string): boolean { + return section.startsWith('terminal.integrated'); + } + }); + + const mainContainer = document.createElement('div'); + document.body.appendChild(mainContainer); + store.add(toDisposable(() => mainContainer.remove())); + xterm.attachToElement(mainContainer); + await timeout(0); + + const iframe = document.createElement('iframe'); + document.body.appendChild(iframe); + store.add(toDisposable(() => iframe.remove())); + const auxiliaryDocument = iframe.contentDocument!; + const auxiliaryContainer = document.createElement('div'); + auxiliaryDocument.body.appendChild(auxiliaryContainer); + const createElement = auxiliaryDocument.createElement; + auxiliaryDocument.createElement = () => { + throw new Error('Not allowed to create elements in child window JavaScript context.'); + }; + store.add(toDisposable(() => auxiliaryDocument.createElement = createElement)); + + auxiliaryContainer.appendChild(xterm.raw.element!); + xterm.raw.open(xterm.raw.element!); + xterm.refresh(); + await timeout(0); + + mainContainer.appendChild(xterm.raw.element!); + xterm.raw.open(xterm.raw.element!); + xterm.refresh(); + await timeout(0); + + deepStrictEqual(TestWebglAddon.customGlyphOptions, [true, false, true]); + }); + + test('does not load stale custom glyph settings when moved during addon import', async () => { + await configurationService.setUserConfiguration('terminal.integrated', { + ...defaultTerminalConfig, + gpuAcceleration: 'on', + customGlyphs: true, + }); + configurationService.onDidChangeConfigurationEmitter.fire(new class extends mock() { + override affectsConfiguration(section: string): boolean { + return section.startsWith('terminal.integrated'); + } + }); + + const mainContainer = document.createElement('div'); + document.body.appendChild(mainContainer); + store.add(toDisposable(() => mainContainer.remove())); + xterm.attachToElement(mainContainer); + + const iframe = document.createElement('iframe'); + document.body.appendChild(iframe); + store.add(toDisposable(() => iframe.remove())); + const auxiliaryDocument = iframe.contentDocument!; + const auxiliaryContainer = document.createElement('div'); + auxiliaryDocument.body.appendChild(auxiliaryContainer); + const createElement = auxiliaryDocument.createElement; + auxiliaryDocument.createElement = () => { + throw new Error('Not allowed to create elements in child window JavaScript context.'); + }; + store.add(toDisposable(() => auxiliaryDocument.createElement = createElement)); + + auxiliaryContainer.appendChild(xterm.raw.element!); + xterm.raw.open(xterm.raw.element!); + xterm.refresh(); + await timeout(0); + + deepStrictEqual(TestWebglAddon.customGlyphOptions, [false]); + }); + suite('getContentsAsText', () => { test('should return all buffer contents when no markers provided', async () => { await write('line 1\r\nline 2\r\nline 3\r\nline 4\r\nline 5'); diff --git a/src/vs/workbench/contrib/terminal/test/browser/xterm/xtermTestUtils.ts b/src/vs/workbench/contrib/terminal/test/browser/xterm/xtermTestUtils.ts index bff945f742a..7f6544bf2d2 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/xterm/xtermTestUtils.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/xterm/xtermTestUtils.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { WebglAddon } from '@xterm/addon-webgl'; +import type { IWebglAddonOptions, WebglAddon } from '@xterm/addon-webgl'; import type { IEvent } from '@xterm/xterm'; import { Emitter } from '../../../../../../base/common/event.js'; import { XtermAddonImporter, type IXtermAddonNameToCtor } from '../../../browser/xterm/xtermAddonImporter.js'; @@ -11,6 +11,7 @@ import { XtermAddonImporter, type IXtermAddonNameToCtor } from '../../../browser export class TestWebglAddon implements WebglAddon { static shouldThrow = false; static isEnabled = false; + static readonly customGlyphOptions: (boolean | undefined)[] = []; private readonly _onChangeTextureAtlas = new Emitter(); private readonly _onAddTextureAtlasCanvas = new Emitter(); private readonly _onRemoveTextureAtlasCanvas = new Emitter(); @@ -19,7 +20,8 @@ export class TestWebglAddon implements WebglAddon { readonly onAddTextureAtlasCanvas = this._onAddTextureAtlasCanvas.event as IEvent; readonly onRemoveTextureAtlasCanvas = this._onRemoveTextureAtlasCanvas.event as IEvent; readonly onContextLoss = this._onContextLoss.event as IEvent; - constructor(preserveDrawingBuffer?: boolean) { + constructor(options?: IWebglAddonOptions) { + TestWebglAddon.customGlyphOptions.push(options?.customGlyphs); } activate(): void { TestWebglAddon.isEnabled = !TestWebglAddon.shouldThrow; @@ -45,4 +47,3 @@ export class TestXtermAddonImporter extends XtermAddonImporter { return super.importAddon(name); } } -