Path: blob/main/src/vs/workbench/services/auxiliaryWindow/browser/auxiliaryWindowService.ts
3296 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 { IDialogService } from '../../../../platform/dialogs/common/dialogs.js';21import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';22import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';23import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js';24import { DEFAULT_AUX_WINDOW_SIZE, IRectangle, WindowMinimumSize } from '../../../../platform/window/common/window.js';25import { BaseWindow } from '../../../browser/window.js';26import { IWorkbenchEnvironmentService } from '../../environment/common/environmentService.js';27import { IHostService } from '../../host/browser/host.js';28import { IWorkbenchLayoutService } from '../../layout/browser/layoutService.js';2930export const IAuxiliaryWindowService = createDecorator<IAuxiliaryWindowService>('auxiliaryWindowService');3132export interface IAuxiliaryWindowOpenEvent {33readonly window: IAuxiliaryWindow;34readonly disposables: DisposableStore;35}3637export enum AuxiliaryWindowMode {38Maximized,39Normal,40Fullscreen41}4243export interface IAuxiliaryWindowOpenOptions {44readonly bounds?: Partial<IRectangle>;45readonly compact?: boolean;4647readonly mode?: AuxiliaryWindowMode;48readonly zoomLevel?: number;49readonly alwaysOnTop?: boolean;5051readonly nativeTitlebar?: boolean;52readonly disableFullscreen?: boolean;53}5455export interface IAuxiliaryWindowService {5657readonly _serviceBrand: undefined;5859readonly onDidOpenAuxiliaryWindow: Event<IAuxiliaryWindowOpenEvent>;6061open(options?: IAuxiliaryWindowOpenOptions): Promise<IAuxiliaryWindow>;6263getWindow(windowId: number): IAuxiliaryWindow | undefined;64}6566export interface BeforeAuxiliaryWindowUnloadEvent {67veto(reason: string | undefined): void;68}6970export interface IAuxiliaryWindow extends IDisposable {7172readonly onWillLayout: Event<Dimension>;73readonly onDidLayout: Event<Dimension>;7475readonly onBeforeUnload: Event<BeforeAuxiliaryWindowUnloadEvent>;76readonly onUnload: Event<void>;7778readonly whenStylesHaveLoaded: Promise<void>;7980readonly window: CodeWindow;81readonly container: HTMLElement;8283updateOptions(options: { compact: boolean } | undefined): void;8485layout(): void;8687createState(): IAuxiliaryWindowOpenOptions;88}8990const DEFAULT_AUX_WINDOW_DIMENSIONS = new Dimension(DEFAULT_AUX_WINDOW_SIZE.width, DEFAULT_AUX_WINDOW_SIZE.height);9192export class AuxiliaryWindow extends BaseWindow implements IAuxiliaryWindow {9394private readonly _onWillLayout = this._register(new Emitter<Dimension>());95readonly onWillLayout = this._onWillLayout.event;9697private readonly _onDidLayout = this._register(new Emitter<Dimension>());98readonly onDidLayout = this._onDidLayout.event;99100private readonly _onBeforeUnload = this._register(new Emitter<BeforeAuxiliaryWindowUnloadEvent>());101readonly onBeforeUnload = this._onBeforeUnload.event;102103private readonly _onUnload = this._register(new Emitter<void>());104readonly onUnload = this._onUnload.event;105106private readonly _onWillDispose = this._register(new Emitter<void>());107readonly onWillDispose = this._onWillDispose.event;108109readonly whenStylesHaveLoaded: Promise<void>;110111private compact = false;112113constructor(114readonly window: CodeWindow,115readonly container: HTMLElement,116stylesHaveLoaded: Barrier,117@IConfigurationService private readonly configurationService: IConfigurationService,118@IHostService hostService: IHostService,119@IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService120) {121super(window, undefined, hostService, environmentService);122123this.whenStylesHaveLoaded = stylesHaveLoaded.wait().then(() => undefined);124125this.registerListeners();126}127128updateOptions(options: { compact: boolean }): void {129this.compact = options.compact;130}131132private registerListeners(): void {133this._register(addDisposableListener(this.window, EventType.BEFORE_UNLOAD, (e: BeforeUnloadEvent) => this.handleBeforeUnload(e)));134this._register(addDisposableListener(this.window, EventType.UNLOAD, () => this.handleUnload()));135136this._register(addDisposableListener(this.window, 'unhandledrejection', e => {137onUnexpectedError(e.reason);138e.preventDefault();139}));140141this._register(addDisposableListener(this.window, EventType.RESIZE, () => this.layout()));142143this._register(addDisposableListener(this.container, EventType.SCROLL, () => this.container.scrollTop = 0)); // Prevent container from scrolling (#55456)144145if (isWeb) {146this._register(addDisposableListener(this.container, EventType.DROP, e => EventHelper.stop(e, true))); // Prevent default navigation on drop147this._register(addDisposableListener(this.container, EventType.WHEEL, e => e.preventDefault(), { passive: false })); // Prevent the back/forward gestures in macOS148this._register(addDisposableListener(this.container, EventType.CONTEXT_MENU, e => EventHelper.stop(e, true))); // Prevent native context menus in web149} else {150this._register(addDisposableListener(this.window.document.body, EventType.DRAG_OVER, (e: DragEvent) => EventHelper.stop(e))); // Prevent drag feedback on <body>151this._register(addDisposableListener(this.window.document.body, EventType.DROP, (e: DragEvent) => EventHelper.stop(e))); // Prevent default navigation on drop152}153}154155private handleBeforeUnload(e: BeforeUnloadEvent): void {156157// Check for veto from a listening component158let veto: string | undefined;159this._onBeforeUnload.fire({160veto(reason) {161if (reason) {162veto = reason;163}164}165});166if (veto) {167this.handleVetoBeforeClose(e, veto);168169return;170}171172// Check for confirm before close setting173const confirmBeforeCloseSetting = this.configurationService.getValue<'always' | 'never' | 'keyboardOnly'>('window.confirmBeforeClose');174const confirmBeforeClose = confirmBeforeCloseSetting === 'always' || (confirmBeforeCloseSetting === 'keyboardOnly' && ModifierKeyEmitter.getInstance().isModifierPressed);175if (confirmBeforeClose) {176this.confirmBeforeClose(e);177}178}179180protected handleVetoBeforeClose(e: BeforeUnloadEvent, reason: string): void {181this.preventUnload(e);182}183184protected preventUnload(e: BeforeUnloadEvent): void {185e.preventDefault();186e.returnValue = localize('lifecycleVeto', "Changes that you made may not be saved. Please check press 'Cancel' and try again.");187}188189protected confirmBeforeClose(e: BeforeUnloadEvent): void {190this.preventUnload(e);191}192193private handleUnload(): void {194195// Event196this._onUnload.fire();197}198199layout(): void {200201// Split layout up into two events so that downstream components202// have a chance to participate in the beginning or end of the203// layout phase.204// This helps to build the auxiliary window in another component205// in the `onWillLayout` phase and then let other compoments206// react when the overall layout has finished in `onDidLayout`.207208const dimension = getClientArea(this.window.document.body, DEFAULT_AUX_WINDOW_DIMENSIONS, this.container);209this._onWillLayout.fire(dimension);210this._onDidLayout.fire(dimension);211}212213createState(): IAuxiliaryWindowOpenOptions {214return {215bounds: {216x: this.window.screenX,217y: this.window.screenY,218width: this.window.outerWidth,219height: this.window.outerHeight220},221zoomLevel: getZoomLevel(this.window),222compact: this.compact223};224}225226override dispose(): void {227if (this._store.isDisposed) {228return;229}230231this._onWillDispose.fire();232233super.dispose();234}235}236237export class BrowserAuxiliaryWindowService extends Disposable implements IAuxiliaryWindowService {238239declare readonly _serviceBrand: undefined;240241private static WINDOW_IDS = getWindowId(mainWindow) + 1; // start from the main window ID + 1242243private readonly _onDidOpenAuxiliaryWindow = this._register(new Emitter<IAuxiliaryWindowOpenEvent>());244readonly onDidOpenAuxiliaryWindow = this._onDidOpenAuxiliaryWindow.event;245246private readonly windows = new Map<number, IAuxiliaryWindow>();247248constructor(249@IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService,250@IDialogService protected readonly dialogService: IDialogService,251@IConfigurationService protected readonly configurationService: IConfigurationService,252@ITelemetryService private readonly telemetryService: ITelemetryService,253@IHostService protected readonly hostService: IHostService,254@IWorkbenchEnvironmentService protected readonly environmentService: IWorkbenchEnvironmentService255) {256super();257}258259async open(options?: IAuxiliaryWindowOpenOptions): Promise<IAuxiliaryWindow> {260mark('code/auxiliaryWindow/willOpen');261262const targetWindow = await this.openWindow(options);263if (!targetWindow) {264throw new Error(localize('unableToOpenWindowError', "Unable to open a new window."));265}266267// Add a `vscodeWindowId` property to identify auxiliary windows268const resolvedWindowId = await this.resolveWindowId(targetWindow);269ensureCodeWindow(targetWindow, resolvedWindowId);270271const containerDisposables = new DisposableStore();272const { container, stylesLoaded } = this.createContainer(targetWindow, containerDisposables, options);273274const auxiliaryWindow = this.createAuxiliaryWindow(targetWindow, container, stylesLoaded);275auxiliaryWindow.updateOptions({ compact: options?.compact ?? false });276277const registryDisposables = new DisposableStore();278this.windows.set(targetWindow.vscodeWindowId, auxiliaryWindow);279registryDisposables.add(toDisposable(() => this.windows.delete(targetWindow.vscodeWindowId)));280281const eventDisposables = new DisposableStore();282283Event.once(auxiliaryWindow.onWillDispose)(() => {284targetWindow.close();285286containerDisposables.dispose();287registryDisposables.dispose();288eventDisposables.dispose();289});290291registryDisposables.add(registerWindow(targetWindow));292this._onDidOpenAuxiliaryWindow.fire({ window: auxiliaryWindow, disposables: eventDisposables });293294mark('code/auxiliaryWindow/didOpen');295296type AuxiliaryWindowClassification = {297owner: 'bpasero';298comment: 'An event that fires when an auxiliary window is opened';299bounds: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Has window bounds provided.' };300};301type AuxiliaryWindowOpenEvent = {302bounds: boolean;303};304this.telemetryService.publicLog2<AuxiliaryWindowOpenEvent, AuxiliaryWindowClassification>('auxiliaryWindowOpen', { bounds: !!options?.bounds });305306return auxiliaryWindow;307}308309protected createAuxiliaryWindow(targetWindow: CodeWindow, container: HTMLElement, stylesLoaded: Barrier): AuxiliaryWindow {310return new AuxiliaryWindow(targetWindow, container, stylesLoaded, this.configurationService, this.hostService, this.environmentService);311}312313private async openWindow(options?: IAuxiliaryWindowOpenOptions): Promise<Window | undefined> {314const activeWindow = getActiveWindow();315const activeWindowBounds = {316x: activeWindow.screenX,317y: activeWindow.screenY,318width: activeWindow.outerWidth,319height: activeWindow.outerHeight320};321322const defaultSize = DEFAULT_AUX_WINDOW_SIZE;323324const width = Math.max(options?.bounds?.width ?? defaultSize.width, WindowMinimumSize.WIDTH);325const height = Math.max(options?.bounds?.height ?? defaultSize.height, WindowMinimumSize.HEIGHT);326327let newWindowBounds: IRectangle = {328x: options?.bounds?.x ?? Math.max(activeWindowBounds.x + activeWindowBounds.width / 2 - width / 2, 0),329y: options?.bounds?.y ?? Math.max(activeWindowBounds.y + activeWindowBounds.height / 2 - height / 2, 0),330width,331height332};333334if (!options?.bounds && newWindowBounds.x === activeWindowBounds.x && newWindowBounds.y === activeWindowBounds.y) {335// Offset the new window a bit so that it does not overlap336// with the active window, unless bounds are provided337newWindowBounds = {338...newWindowBounds,339x: newWindowBounds.x + 30,340y: newWindowBounds.y + 30341};342}343344const features = coalesce([345'popup=yes',346`left=${newWindowBounds.x}`,347`top=${newWindowBounds.y}`,348`width=${newWindowBounds.width}`,349`height=${newWindowBounds.height}`,350351// non-standard properties352options?.nativeTitlebar ? 'window-native-titlebar=yes' : undefined,353options?.disableFullscreen ? 'window-disable-fullscreen=yes' : undefined,354options?.alwaysOnTop ? 'window-always-on-top=yes' : undefined,355options?.mode === AuxiliaryWindowMode.Maximized ? 'window-maximized=yes' : undefined,356options?.mode === AuxiliaryWindowMode.Fullscreen ? 'window-fullscreen=yes' : undefined357]);358359const auxiliaryWindow = mainWindow.open(isFirefox ? '' /* FF immediately fires an unload event if using about:blank */ : 'about:blank', undefined, features.join(','));360if (!auxiliaryWindow && isWeb) {361return (await this.dialogService.prompt({362type: Severity.Warning,363message: localize('unableToOpenWindow', "The browser blocked opening a new window. Press 'Retry' to try again."),364custom: {365markdownDetails: [{ markdown: new MarkdownString(localize('unableToOpenWindowDetail', "Please allow pop-ups for this website in your [browser settings]({0}).", 'https://aka.ms/allow-vscode-popup'), true) }]366},367buttons: [368{369label: localize({ key: 'retry', comment: ['&& denotes a mnemonic'] }, "&&Retry"),370run: () => this.openWindow(options)371}372],373cancelButton: true374})).result;375}376377return auxiliaryWindow?.window;378}379380protected async resolveWindowId(auxiliaryWindow: Window): Promise<number> {381return BrowserAuxiliaryWindowService.WINDOW_IDS++;382}383384protected createContainer(auxiliaryWindow: CodeWindow, disposables: DisposableStore, options?: IAuxiliaryWindowOpenOptions): { stylesLoaded: Barrier; container: HTMLElement } {385auxiliaryWindow.document.createElement = function () {386// Disallow `createElement` because it would create387// HTML Elements in the "wrong" context and break388// code that does "instanceof HTMLElement" etc.389throw 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.');390};391392this.applyMeta(auxiliaryWindow);393const { stylesLoaded } = this.applyCSS(auxiliaryWindow, disposables);394const container = this.applyHTML(auxiliaryWindow, disposables);395396return { stylesLoaded, container };397}398399private applyMeta(auxiliaryWindow: CodeWindow): void {400for (const metaTag of ['meta[charset="utf-8"]', 'meta[http-equiv="Content-Security-Policy"]', 'meta[name="viewport"]', 'meta[name="theme-color"]']) {401const metaElement = mainWindow.document.querySelector(metaTag);402if (metaElement) {403const clonedMetaElement = createMetaElement(auxiliaryWindow.document.head);404copyAttributes(metaElement, clonedMetaElement);405406if (metaTag === 'meta[http-equiv="Content-Security-Policy"]') {407const content = clonedMetaElement.getAttribute('content');408if (content) {409clonedMetaElement.setAttribute('content', content.replace(/(script-src[^\;]*)/, `script-src 'none'`));410}411}412}413}414415const originalIconLinkTag = mainWindow.document.querySelector('link[rel="icon"]');416if (originalIconLinkTag) {417const icon = createLinkElement(auxiliaryWindow.document.head);418copyAttributes(originalIconLinkTag, icon);419}420}421422private applyCSS(auxiliaryWindow: CodeWindow, disposables: DisposableStore) {423mark('code/auxiliaryWindow/willApplyCSS');424425const mapOriginalToClone = new Map<Node /* original */, Node /* clone */>();426427const stylesLoaded = new Barrier();428stylesLoaded.wait().then(() => mark('code/auxiliaryWindow/didLoadCSSStyles'));429430const pendingLinksDisposables = disposables.add(new DisposableStore());431432let pendingLinksToSettle = 0;433function onLinkSettled() {434if (--pendingLinksToSettle === 0) {435pendingLinksDisposables.dispose();436stylesLoaded.open();437}438}439440function cloneNode(originalNode: Element): void {441if (isGlobalStylesheet(originalNode)) {442return; // global stylesheets are handled by `cloneGlobalStylesheets` below443}444445const clonedNode = auxiliaryWindow.document.head.appendChild(originalNode.cloneNode(true));446if (originalNode.tagName.toLowerCase() === 'link') {447pendingLinksToSettle++;448449pendingLinksDisposables.add(addDisposableListener(clonedNode, 'load', onLinkSettled));450pendingLinksDisposables.add(addDisposableListener(clonedNode, 'error', onLinkSettled));451}452453mapOriginalToClone.set(originalNode, clonedNode);454}455456// Clone all style elements and stylesheet links from the window to the child window457// and keep track of <link> elements to settle to signal that styles have loaded458// Increment pending links right from the beginning to ensure we only settle when459// all style related nodes have been cloned.460pendingLinksToSettle++;461try {462for (const originalNode of mainWindow.document.head.querySelectorAll('link[rel="stylesheet"], style')) {463cloneNode(originalNode);464}465} finally {466onLinkSettled();467}468469// Global stylesheets in <head> are cloned in a special way because the mutation470// observer is not firing for changes done via `style.sheet` API. Only text changes471// can be observed.472disposables.add(cloneGlobalStylesheets(auxiliaryWindow));473474// Listen to new stylesheets as they are being added or removed in the main window475// and apply to child window (including changes to existing stylesheets elements)476disposables.add(sharedMutationObserver.observe(mainWindow.document.head, disposables, { childList: true, subtree: true })(mutations => {477for (const mutation of mutations) {478if (479mutation.type !== 'childList' || // only interested in added/removed nodes480mutation.target.nodeName.toLowerCase() === 'title' || // skip over title changes that happen frequently481mutation.target.nodeName.toLowerCase() === 'script' || // block <script> changes that are unsupported anyway482mutation.target.nodeName.toLowerCase() === 'meta' // do not observe <meta> elements for now483) {484continue;485}486487for (const node of mutation.addedNodes) {488489// <style>/<link> element was added490if (isHTMLElement(node) && (node.tagName.toLowerCase() === 'style' || node.tagName.toLowerCase() === 'link')) {491cloneNode(node);492}493494// text-node was changed, try to apply to our clones495else if (node.nodeType === Node.TEXT_NODE && node.parentNode) {496const clonedNode = mapOriginalToClone.get(node.parentNode);497if (clonedNode) {498clonedNode.textContent = node.textContent;499}500}501}502503for (const node of mutation.removedNodes) {504const clonedNode = mapOriginalToClone.get(node);505if (clonedNode) {506clonedNode.parentNode?.removeChild(clonedNode);507mapOriginalToClone.delete(node);508}509}510}511}));512513mark('code/auxiliaryWindow/didApplyCSS');514515return { stylesLoaded };516}517518private applyHTML(auxiliaryWindow: CodeWindow, disposables: DisposableStore): HTMLElement {519mark('code/auxiliaryWindow/willApplyHTML');520521// Create workbench container and apply classes522const container = $('div', { role: 'application' });523position(container, 0, 0, 0, 0, 'relative');524container.style.display = 'flex';525container.style.height = '100%';526container.style.flexDirection = 'column';527auxiliaryWindow.document.body.append(container);528529// Track attributes530disposables.add(trackAttributes(mainWindow.document.documentElement, auxiliaryWindow.document.documentElement));531disposables.add(trackAttributes(mainWindow.document.body, auxiliaryWindow.document.body));532disposables.add(trackAttributes(this.layoutService.mainContainer, container, ['class'])); // only class attribute533534mark('code/auxiliaryWindow/didApplyHTML');535536return container;537}538539getWindow(windowId: number): IAuxiliaryWindow | undefined {540return this.windows.get(windowId);541}542}543544registerSingleton(IAuxiliaryWindowService, BrowserAuxiliaryWindowService, InstantiationType.Delayed);545546547