Path: blob/main/src/vs/workbench/services/auxiliaryWindow/browser/auxiliaryWindowService.ts
5241 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { getZoomLevel } from '../../../../base/browser/browser.js';6import { $, Dimension, EventHelper, EventType, ModifierKeyEmitter, addDisposableListener, copyAttributes, createLinkElement, createMetaElement, getActiveWindow, getClientArea, getWindowId, isHTMLElement, position, registerWindow, sharedMutationObserver, trackAttributes } from '../../../../base/browser/dom.js';7import { cloneGlobalStylesheets, isGlobalStylesheet } from '../../../../base/browser/domStylesheets.js';8import { CodeWindow, ensureCodeWindow, mainWindow } from '../../../../base/browser/window.js';9import { coalesce } from '../../../../base/common/arrays.js';10import { Barrier } from '../../../../base/common/async.js';11import { onUnexpectedError } from '../../../../base/common/errors.js';12import { Emitter, Event } from '../../../../base/common/event.js';13import { MarkdownString } from '../../../../base/common/htmlContent.js';14import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js';15import { mark } from '../../../../base/common/performance.js';16import { isFirefox, isWeb } from '../../../../base/common/platform.js';17import Severity from '../../../../base/common/severity.js';18import { localize } from '../../../../nls.js';19import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';20import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js';21import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js';22import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';23import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';24import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js';25import { DEFAULT_AUX_WINDOW_SIZE, IRectangle, WindowMinimumSize } from '../../../../platform/window/common/window.js';26import { BaseWindow } from '../../../browser/window.js';27import { IWorkbenchEnvironmentService } from '../../environment/common/environmentService.js';28import { IHostService } from '../../host/browser/host.js';29import { IWorkbenchLayoutService } from '../../layout/browser/layoutService.js';3031export const IAuxiliaryWindowService = createDecorator<IAuxiliaryWindowService>('auxiliaryWindowService');3233export interface IAuxiliaryWindowOpenEvent {34readonly window: IAuxiliaryWindow;35readonly disposables: DisposableStore;36}3738export enum AuxiliaryWindowMode {39Maximized,40Normal,41Fullscreen42}4344export interface IAuxiliaryWindowOpenOptions {45readonly bounds?: Partial<IRectangle>;46readonly compact?: boolean;4748readonly mode?: AuxiliaryWindowMode;49readonly zoomLevel?: number;50readonly alwaysOnTop?: boolean;5152readonly nativeTitlebar?: boolean;53readonly disableFullscreen?: boolean;54}5556export interface IAuxiliaryWindowService {5758readonly _serviceBrand: undefined;5960readonly onDidOpenAuxiliaryWindow: Event<IAuxiliaryWindowOpenEvent>;6162open(options?: IAuxiliaryWindowOpenOptions): Promise<IAuxiliaryWindow>;6364getWindow(windowId: number): IAuxiliaryWindow | undefined;65}6667export interface BeforeAuxiliaryWindowUnloadEvent {68veto(reason: string | undefined): void;69}7071export interface IAuxiliaryWindow extends IDisposable {7273readonly onWillLayout: Event<Dimension>;74readonly onDidLayout: Event<Dimension>;7576readonly onBeforeUnload: Event<BeforeAuxiliaryWindowUnloadEvent>;77readonly onUnload: Event<void>;7879readonly whenStylesHaveLoaded: Promise<void>;8081readonly window: CodeWindow;82readonly container: HTMLElement;8384updateOptions(options: { compact: boolean } | undefined): void;8586layout(): void;8788createState(): IAuxiliaryWindowOpenOptions;89}9091const DEFAULT_AUX_WINDOW_DIMENSIONS = new Dimension(DEFAULT_AUX_WINDOW_SIZE.width, DEFAULT_AUX_WINDOW_SIZE.height);9293export class AuxiliaryWindow extends BaseWindow implements IAuxiliaryWindow {9495private readonly _onWillLayout = this._register(new Emitter<Dimension>());96readonly onWillLayout = this._onWillLayout.event;9798private readonly _onDidLayout = this._register(new Emitter<Dimension>());99readonly onDidLayout = this._onDidLayout.event;100101private readonly _onBeforeUnload = this._register(new Emitter<BeforeAuxiliaryWindowUnloadEvent>());102readonly onBeforeUnload = this._onBeforeUnload.event;103104private readonly _onUnload = this._register(new Emitter<void>());105readonly onUnload = this._onUnload.event;106107private readonly _onWillDispose = this._register(new Emitter<void>());108readonly onWillDispose = this._onWillDispose.event;109110readonly whenStylesHaveLoaded: Promise<void>;111112private compact = false;113114constructor(115readonly window: CodeWindow,116readonly container: HTMLElement,117stylesHaveLoaded: Barrier,118@IConfigurationService private readonly configurationService: IConfigurationService,119@IHostService hostService: IHostService,120@IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService,121@IContextMenuService contextMenuService: IContextMenuService,122@IWorkbenchLayoutService layoutService: IWorkbenchLayoutService123) {124super(window, undefined, hostService, environmentService, contextMenuService, layoutService);125126this.whenStylesHaveLoaded = stylesHaveLoaded.wait().then(() => undefined);127128this.registerListeners();129}130131updateOptions(options: { compact: boolean }): void {132this.compact = options.compact;133}134135private registerListeners(): void {136this._register(addDisposableListener(this.window, EventType.BEFORE_UNLOAD, (e: BeforeUnloadEvent) => this.handleBeforeUnload(e)));137this._register(addDisposableListener(this.window, EventType.UNLOAD, () => this.handleUnload()));138139this._register(addDisposableListener(this.window, 'unhandledrejection', e => {140onUnexpectedError(e.reason);141e.preventDefault();142}));143144this._register(addDisposableListener(this.window, EventType.RESIZE, () => this.layout()));145146this._register(addDisposableListener(this.container, EventType.SCROLL, () => this.container.scrollTop = 0)); // Prevent container from scrolling (#55456)147148if (isWeb) {149this._register(addDisposableListener(this.container, EventType.DROP, e => EventHelper.stop(e, true))); // Prevent default navigation on drop150this._register(addDisposableListener(this.container, EventType.WHEEL, e => e.preventDefault(), { passive: false })); // Prevent the back/forward gestures in macOS151this._register(addDisposableListener(this.container, EventType.CONTEXT_MENU, e => EventHelper.stop(e, true))); // Prevent native context menus in web152} else {153this._register(addDisposableListener(this.window.document.body, EventType.DRAG_OVER, (e: DragEvent) => EventHelper.stop(e))); // Prevent drag feedback on <body>154this._register(addDisposableListener(this.window.document.body, EventType.DROP, (e: DragEvent) => EventHelper.stop(e))); // Prevent default navigation on drop155}156}157158private handleBeforeUnload(e: BeforeUnloadEvent): void {159160// Check for veto from a listening component161let veto: string | undefined;162this._onBeforeUnload.fire({163veto(reason) {164if (reason) {165veto = reason;166}167}168});169if (veto) {170this.handleVetoBeforeClose(e, veto);171172return;173}174175// Check for confirm before close setting176const confirmBeforeCloseSetting = this.configurationService.getValue<'always' | 'never' | 'keyboardOnly'>('window.confirmBeforeClose');177const confirmBeforeClose = confirmBeforeCloseSetting === 'always' || (confirmBeforeCloseSetting === 'keyboardOnly' && ModifierKeyEmitter.getInstance().isModifierPressed);178if (confirmBeforeClose) {179this.confirmBeforeClose(e);180}181}182183protected handleVetoBeforeClose(e: BeforeUnloadEvent, reason: string): void {184this.preventUnload(e);185}186187protected preventUnload(e: BeforeUnloadEvent): void {188e.preventDefault();189e.returnValue = localize('lifecycleVeto', "Changes that you made may not be saved. Please check press 'Cancel' and try again.");190}191192protected confirmBeforeClose(e: BeforeUnloadEvent): void {193this.preventUnload(e);194}195196private handleUnload(): void {197198// Event199this._onUnload.fire();200}201202layout(): void {203204// Split layout up into two events so that downstream components205// have a chance to participate in the beginning or end of the206// layout phase.207// This helps to build the auxiliary window in another component208// in the `onWillLayout` phase and then let other compoments209// react when the overall layout has finished in `onDidLayout`.210211const dimension = getClientArea(this.window.document.body, DEFAULT_AUX_WINDOW_DIMENSIONS, this.container);212this._onWillLayout.fire(dimension);213this._onDidLayout.fire(dimension);214}215216createState(): IAuxiliaryWindowOpenOptions {217return {218bounds: {219x: this.window.screenX,220y: this.window.screenY,221width: this.window.outerWidth,222height: this.window.outerHeight223},224zoomLevel: getZoomLevel(this.window),225compact: this.compact226};227}228229override dispose(): void {230if (this._store.isDisposed) {231return;232}233234this._onWillDispose.fire();235236super.dispose();237}238}239240export class BrowserAuxiliaryWindowService extends Disposable implements IAuxiliaryWindowService {241242declare readonly _serviceBrand: undefined;243244private static WINDOW_IDS = getWindowId(mainWindow) + 1; // start from the main window ID + 1245246private readonly _onDidOpenAuxiliaryWindow = this._register(new Emitter<IAuxiliaryWindowOpenEvent>());247readonly onDidOpenAuxiliaryWindow = this._onDidOpenAuxiliaryWindow.event;248249private readonly windows = new Map<number, IAuxiliaryWindow>();250251constructor(252@IWorkbenchLayoutService protected readonly layoutService: IWorkbenchLayoutService,253@IDialogService protected readonly dialogService: IDialogService,254@IConfigurationService protected readonly configurationService: IConfigurationService,255@ITelemetryService private readonly telemetryService: ITelemetryService,256@IHostService protected readonly hostService: IHostService,257@IWorkbenchEnvironmentService protected readonly environmentService: IWorkbenchEnvironmentService,258@IContextMenuService protected readonly contextMenuService: IContextMenuService,259) {260super();261}262263async open(options?: IAuxiliaryWindowOpenOptions): Promise<IAuxiliaryWindow> {264mark('code/auxiliaryWindow/willOpen');265266const targetWindow = await this.openWindow(options);267if (!targetWindow) {268throw new Error(localize('unableToOpenWindowError', "Unable to open a new window."));269}270271// Add a `vscodeWindowId` property to identify auxiliary windows272const resolvedWindowId = await this.resolveWindowId(targetWindow);273ensureCodeWindow(targetWindow, resolvedWindowId);274275const containerDisposables = new DisposableStore();276const { container, stylesLoaded } = this.createContainer(targetWindow, containerDisposables, options);277278const auxiliaryWindow = this.createAuxiliaryWindow(targetWindow, container, stylesLoaded);279auxiliaryWindow.updateOptions({ compact: options?.compact ?? false });280281const registryDisposables = new DisposableStore();282this.windows.set(targetWindow.vscodeWindowId, auxiliaryWindow);283registryDisposables.add(toDisposable(() => this.windows.delete(targetWindow.vscodeWindowId)));284285const eventDisposables = new DisposableStore();286287Event.once(auxiliaryWindow.onWillDispose)(() => {288targetWindow.close();289290containerDisposables.dispose();291registryDisposables.dispose();292eventDisposables.dispose();293});294295registryDisposables.add(registerWindow(targetWindow));296this._onDidOpenAuxiliaryWindow.fire({ window: auxiliaryWindow, disposables: eventDisposables });297298mark('code/auxiliaryWindow/didOpen');299300type AuxiliaryWindowClassification = {301owner: 'bpasero';302comment: 'An event that fires when an auxiliary window is opened';303bounds: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Has window bounds provided.' };304};305type AuxiliaryWindowOpenEvent = {306bounds: boolean;307};308this.telemetryService.publicLog2<AuxiliaryWindowOpenEvent, AuxiliaryWindowClassification>('auxiliaryWindowOpen', { bounds: !!options?.bounds });309310return auxiliaryWindow;311}312313protected createAuxiliaryWindow(targetWindow: CodeWindow, container: HTMLElement, stylesLoaded: Barrier): AuxiliaryWindow {314return new AuxiliaryWindow(targetWindow, container, stylesLoaded, this.configurationService, this.hostService, this.environmentService, this.contextMenuService, this.layoutService);315}316317private async openWindow(options?: IAuxiliaryWindowOpenOptions): Promise<Window | undefined> {318const activeWindow = getActiveWindow();319const activeWindowBounds = {320x: activeWindow.screenX,321y: activeWindow.screenY,322width: activeWindow.outerWidth,323height: activeWindow.outerHeight324};325326const defaultSize = DEFAULT_AUX_WINDOW_SIZE;327328const width = Math.max(options?.bounds?.width ?? defaultSize.width, WindowMinimumSize.WIDTH);329const height = Math.max(options?.bounds?.height ?? defaultSize.height, WindowMinimumSize.HEIGHT);330331let newWindowBounds: IRectangle = {332x: options?.bounds?.x ?? Math.max(activeWindowBounds.x + activeWindowBounds.width / 2 - width / 2, 0),333y: options?.bounds?.y ?? Math.max(activeWindowBounds.y + activeWindowBounds.height / 2 - height / 2, 0),334width,335height336};337338if (!options?.bounds && newWindowBounds.x === activeWindowBounds.x && newWindowBounds.y === activeWindowBounds.y) {339// Offset the new window a bit so that it does not overlap340// with the active window, unless bounds are provided341newWindowBounds = {342...newWindowBounds,343x: newWindowBounds.x + 30,344y: newWindowBounds.y + 30345};346}347348const features = coalesce([349'popup=yes',350`left=${newWindowBounds.x}`,351`top=${newWindowBounds.y}`,352`width=${newWindowBounds.width}`,353`height=${newWindowBounds.height}`,354355// non-standard properties356options?.nativeTitlebar ? 'window-native-titlebar=yes' : undefined,357options?.disableFullscreen ? 'window-disable-fullscreen=yes' : undefined,358options?.alwaysOnTop ? 'window-always-on-top=yes' : undefined,359options?.mode === AuxiliaryWindowMode.Maximized ? 'window-maximized=yes' : undefined,360options?.mode === AuxiliaryWindowMode.Fullscreen ? 'window-fullscreen=yes' : undefined361]);362363const auxiliaryWindow = mainWindow.open(isFirefox ? '' /* FF immediately fires an unload event if using about:blank */ : 'about:blank', undefined, features.join(','));364if (!auxiliaryWindow && isWeb) {365return (await this.dialogService.prompt({366type: Severity.Warning,367message: localize('unableToOpenWindow', "The browser blocked opening a new window. Press 'Retry' to try again."),368custom: {369markdownDetails: [{ markdown: new MarkdownString(localize('unableToOpenWindowDetail', "Please allow pop-ups for this website in your [browser settings]({0}).", 'https://aka.ms/allow-vscode-popup'), true) }]370},371buttons: [372{373label: localize({ key: 'retry', comment: ['&& denotes a mnemonic'] }, "&&Retry"),374run: () => this.openWindow(options)375}376],377cancelButton: true378})).result;379}380381return auxiliaryWindow?.window;382}383384protected async resolveWindowId(auxiliaryWindow: Window): Promise<number> {385return BrowserAuxiliaryWindowService.WINDOW_IDS++;386}387388protected createContainer(auxiliaryWindow: CodeWindow, disposables: DisposableStore, options?: IAuxiliaryWindowOpenOptions): { stylesLoaded: Barrier; container: HTMLElement } {389auxiliaryWindow.document.createElement = function () {390// Disallow `createElement` because it would create391// HTML Elements in the "wrong" context and break392// code that does "instanceof HTMLElement" etc.393throw new Error('Not allowed to create elements in child window JavaScript context. Always use the main window so that "xyz instanceof HTMLElement" continues to work.');394};395396this.applyMeta(auxiliaryWindow);397const { stylesLoaded } = this.applyCSS(auxiliaryWindow, disposables);398const container = this.applyHTML(auxiliaryWindow, disposables);399400return { stylesLoaded, container };401}402403private applyMeta(auxiliaryWindow: CodeWindow): void {404for (const metaTag of ['meta[charset="utf-8"]', 'meta[http-equiv="Content-Security-Policy"]', 'meta[name="viewport"]', 'meta[name="theme-color"]']) {405// eslint-disable-next-line no-restricted-syntax406const metaElement = mainWindow.document.querySelector(metaTag);407if (metaElement) {408const clonedMetaElement = createMetaElement(auxiliaryWindow.document.head);409copyAttributes(metaElement, clonedMetaElement);410411if (metaTag === 'meta[http-equiv="Content-Security-Policy"]') {412const content = clonedMetaElement.getAttribute('content');413if (content) {414clonedMetaElement.setAttribute('content', content.replace(/(script-src[^\;]*)/, `script-src 'none'`));415}416}417}418}419420// eslint-disable-next-line no-restricted-syntax421const originalIconLinkTag = mainWindow.document.querySelector('link[rel="icon"]');422if (originalIconLinkTag) {423const icon = createLinkElement(auxiliaryWindow.document.head);424copyAttributes(originalIconLinkTag, icon);425}426}427428private applyCSS(auxiliaryWindow: CodeWindow, disposables: DisposableStore) {429mark('code/auxiliaryWindow/willApplyCSS');430431const mapOriginalToClone = new Map<Node /* original */, Node /* clone */>();432433const stylesLoaded = new Barrier();434stylesLoaded.wait().then(() => mark('code/auxiliaryWindow/didLoadCSSStyles'));435436const pendingLinksDisposables = disposables.add(new DisposableStore());437438let pendingLinksToSettle = 0;439function onLinkSettled() {440if (--pendingLinksToSettle === 0) {441pendingLinksDisposables.dispose();442stylesLoaded.open();443}444}445446function cloneNode(originalNode: Element): void {447if (isGlobalStylesheet(originalNode)) {448return; // global stylesheets are handled by `cloneGlobalStylesheets` below449}450451const clonedNode = auxiliaryWindow.document.head.appendChild(originalNode.cloneNode(true));452if (originalNode.tagName.toLowerCase() === 'link') {453pendingLinksToSettle++;454455pendingLinksDisposables.add(addDisposableListener(clonedNode, 'load', onLinkSettled));456pendingLinksDisposables.add(addDisposableListener(clonedNode, 'error', onLinkSettled));457}458459mapOriginalToClone.set(originalNode, clonedNode);460}461462// Clone all style elements and stylesheet links from the window to the child window463// and keep track of <link> elements to settle to signal that styles have loaded464// Increment pending links right from the beginning to ensure we only settle when465// all style related nodes have been cloned.466pendingLinksToSettle++;467try {468// eslint-disable-next-line no-restricted-syntax469for (const originalNode of mainWindow.document.head.querySelectorAll('link[rel="stylesheet"], style')) {470cloneNode(originalNode);471}472} finally {473onLinkSettled();474}475476// Global stylesheets in <head> are cloned in a special way because the mutation477// observer is not firing for changes done via `style.sheet` API. Only text changes478// can be observed.479disposables.add(cloneGlobalStylesheets(auxiliaryWindow));480481// Listen to new stylesheets as they are being added or removed in the main window482// and apply to child window (including changes to existing stylesheets elements)483disposables.add(sharedMutationObserver.observe(mainWindow.document.head, disposables, { childList: true, subtree: true })(mutations => {484for (const mutation of mutations) {485if (486mutation.type !== 'childList' || // only interested in added/removed nodes487mutation.target.nodeName.toLowerCase() === 'title' || // skip over title changes that happen frequently488mutation.target.nodeName.toLowerCase() === 'script' || // block <script> changes that are unsupported anyway489mutation.target.nodeName.toLowerCase() === 'meta' // do not observe <meta> elements for now490) {491continue;492}493494for (const node of mutation.addedNodes) {495496// <style>/<link> element was added497if (isHTMLElement(node) && (node.tagName.toLowerCase() === 'style' || node.tagName.toLowerCase() === 'link')) {498cloneNode(node);499}500501// text-node was changed, try to apply to our clones502else if (node.nodeType === Node.TEXT_NODE && node.parentNode) {503const clonedNode = mapOriginalToClone.get(node.parentNode);504if (clonedNode) {505clonedNode.textContent = node.textContent;506}507}508}509510for (const node of mutation.removedNodes) {511const clonedNode = mapOriginalToClone.get(node);512if (clonedNode) {513clonedNode.parentNode?.removeChild(clonedNode);514mapOriginalToClone.delete(node);515}516}517}518}));519520mark('code/auxiliaryWindow/didApplyCSS');521522return { stylesLoaded };523}524525private applyHTML(auxiliaryWindow: CodeWindow, disposables: DisposableStore): HTMLElement {526mark('code/auxiliaryWindow/willApplyHTML');527528// Create workbench container and apply classes529const container = $('div', { role: 'application' });530position(container, 0, 0, 0, 0, 'relative');531container.style.display = 'flex';532container.style.height = '100%';533container.style.flexDirection = 'column';534auxiliaryWindow.document.body.append(container);535536// Track attributes537disposables.add(trackAttributes(mainWindow.document.documentElement, auxiliaryWindow.document.documentElement));538disposables.add(trackAttributes(mainWindow.document.body, auxiliaryWindow.document.body));539disposables.add(trackAttributes(this.layoutService.mainContainer, container, ['class'])); // only class attribute540541mark('code/auxiliaryWindow/didApplyHTML');542543return container;544}545546getWindow(windowId: number): IAuxiliaryWindow | undefined {547return this.windows.get(windowId);548}549}550551registerSingleton(IAuxiliaryWindowService, BrowserAuxiliaryWindowService, InstantiationType.Delayed);552553554