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>
This commit is contained in:
Dmitriy Vasyura
2026-08-14 09:05:19 -07:00
committed by GitHub
co-authored by Copilot
parent 594d846898
commit 74f75d9ec5
19 changed files with 480 additions and 75 deletions
@@ -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 = $<HTMLDivElement>('div');
container.style.position = 'absolute';
container.style.top = '-50000px';
container.style.width = '50000px';
const regularDomNode = targetDocument.createElement('span');
const regularDomNode = $<HTMLSpanElement>('span');
applyFontInfo(regularDomNode, fontInfo);
regularDomNode.style.whiteSpace = 'pre'; // just like the textarea
regularDomNode.style.tabSize = `${tabSize * fontInfo.spaceWidth}px`; // just like the textarea
@@ -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.$<HTMLCanvasElement>('canvas').getContext('2d');
}
let maxTextWidth: number;
@@ -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<IMarkdownRendererService>() { }],
[IMenuService, new class extends mock<IMenuService>() {
override createMenu(): IMenu {
return new class extends mock<IMenu>() {
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,
});
});
});
@@ -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 = $<HTMLButtonElement>('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 = $<HTMLSpanElement>('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 = $<HTMLSpanElement>('span');
streakSpan.className = 'agents-aquarium-toggle-streak';
streakSpan.setAttribute('aria-hidden', 'true');
if (active) {
const hungerIconSpan = button.ownerDocument.createElement('span');
const hungerIconSpan = $<HTMLSpanElement>('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 = $<HTMLDivElement>('.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) });
@@ -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 = $<HTMLDivElement>('.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 = $<HTMLDivElement>('.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 `<use>` 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<SVGSVGElement>('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 `<use href="#agents-aquarium-fish-logo">`,
// 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<SVGDefsElement>('defs');
for (let i = 0; i < NUM_BODY_STRIPS; i++) {
const clip = targetDocument.createElementNS(SVG_NS, 'clipPath');
const clip = $.SVG<SVGClipPathElement>('clipPath');
clip.setAttribute('id', `agents-aquarium-fish-clip-${i}`);
clip.setAttribute('clipPathUnits', 'userSpaceOnUse');
const rect = targetDocument.createElementNS(SVG_NS, 'rect');
const rect = $.SVG<SVGRectElement>('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<SVGSymbolElement>('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<SVGPathElement>('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<SVGSVGElement>('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<SVGGElement>('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<SVGGElement>('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<SVGUseElement>('use');
stripUse.setAttribute('href', `#${SHARED_LOGO_SYMBOL_ID}`);
stripUse.setAttribute('clip-path', `url(#agents-aquarium-fish-clip-${i})`);
stripG.appendChild(stripUse);
@@ -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<IWorkbenchLayoutService>() {
override readonly mainContainer = document.createElement('div');
}();
const hoverService = new class extends mock<IHoverService>() {
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,
});
});
});
@@ -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<SVGSVGElement>('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<SVGCircleElement>('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<SVGCircleElement>('circle');
progress.classList.add('dictation-download-ring-progress');
progress.setAttribute('cx', '8');
progress.setAttribute('cy', '8');
@@ -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()));
@@ -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);
}
@@ -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 = $<HTMLSpanElement>('span');
span.classList.add('chat-progress-shimmer-text');
span.textContent = shimmerText;
node.parentNode?.insertBefore(span, node);
@@ -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);
}
@@ -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 = <T extends IDisposable>(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()));
@@ -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<IChatSpeechToTextService>() {
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,
});
});
});
@@ -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,
});
});
@@ -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', () => {
'<p>before after</p>');
});
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: '<code>a.ts</code>',
ownerDocument: true,
mainRealmElement: true,
});
});
test('produces markdown without internal targets when pasted back into chat', () => {
const copied = sanitizeToHtml(
'<p>This is <strong>inherited</strong> from the <a href="" data-href="file:///repo/src/FooBar.ts">FooBar</a> class. '
@@ -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'));
@@ -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<LigaturesAddonType> = 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<void> {
// 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<void> {
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<void> {
@@ -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<IConfigurationChangeEvent>() {
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<IConfigurationChangeEvent>() {
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');
@@ -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<HTMLCanvasElement>();
private readonly _onAddTextureAtlasCanvas = new Emitter<HTMLCanvasElement>();
private readonly _onRemoveTextureAtlasCanvas = new Emitter<HTMLCanvasElement>();
@@ -19,7 +20,8 @@ export class TestWebglAddon implements WebglAddon {
readonly onAddTextureAtlasCanvas = this._onAddTextureAtlasCanvas.event as IEvent<HTMLCanvasElement>;
readonly onRemoveTextureAtlasCanvas = this._onRemoveTextureAtlasCanvas.event as IEvent<HTMLCanvasElement, void>;
readonly onContextLoss = this._onContextLoss.event as IEvent<void>;
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);
}
}