Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/services/auxiliaryWindow/browser/auxiliaryWindowService.ts
3296 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { getZoomLevel } from '../../../../base/browser/browser.js';
7
import { $, Dimension, EventHelper, EventType, ModifierKeyEmitter, addDisposableListener, copyAttributes, createLinkElement, createMetaElement, getActiveWindow, getClientArea, getWindowId, isHTMLElement, position, registerWindow, sharedMutationObserver, trackAttributes } from '../../../../base/browser/dom.js';
8
import { cloneGlobalStylesheets, isGlobalStylesheet } from '../../../../base/browser/domStylesheets.js';
9
import { CodeWindow, ensureCodeWindow, mainWindow } from '../../../../base/browser/window.js';
10
import { coalesce } from '../../../../base/common/arrays.js';
11
import { Barrier } from '../../../../base/common/async.js';
12
import { onUnexpectedError } from '../../../../base/common/errors.js';
13
import { Emitter, Event } from '../../../../base/common/event.js';
14
import { MarkdownString } from '../../../../base/common/htmlContent.js';
15
import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js';
16
import { mark } from '../../../../base/common/performance.js';
17
import { isFirefox, isWeb } from '../../../../base/common/platform.js';
18
import Severity from '../../../../base/common/severity.js';
19
import { localize } from '../../../../nls.js';
20
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
21
import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js';
22
import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';
23
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
24
import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js';
25
import { DEFAULT_AUX_WINDOW_SIZE, IRectangle, WindowMinimumSize } from '../../../../platform/window/common/window.js';
26
import { BaseWindow } from '../../../browser/window.js';
27
import { IWorkbenchEnvironmentService } from '../../environment/common/environmentService.js';
28
import { IHostService } from '../../host/browser/host.js';
29
import { IWorkbenchLayoutService } from '../../layout/browser/layoutService.js';
30
31
export const IAuxiliaryWindowService = createDecorator<IAuxiliaryWindowService>('auxiliaryWindowService');
32
33
export interface IAuxiliaryWindowOpenEvent {
34
readonly window: IAuxiliaryWindow;
35
readonly disposables: DisposableStore;
36
}
37
38
export enum AuxiliaryWindowMode {
39
Maximized,
40
Normal,
41
Fullscreen
42
}
43
44
export interface IAuxiliaryWindowOpenOptions {
45
readonly bounds?: Partial<IRectangle>;
46
readonly compact?: boolean;
47
48
readonly mode?: AuxiliaryWindowMode;
49
readonly zoomLevel?: number;
50
readonly alwaysOnTop?: boolean;
51
52
readonly nativeTitlebar?: boolean;
53
readonly disableFullscreen?: boolean;
54
}
55
56
export interface IAuxiliaryWindowService {
57
58
readonly _serviceBrand: undefined;
59
60
readonly onDidOpenAuxiliaryWindow: Event<IAuxiliaryWindowOpenEvent>;
61
62
open(options?: IAuxiliaryWindowOpenOptions): Promise<IAuxiliaryWindow>;
63
64
getWindow(windowId: number): IAuxiliaryWindow | undefined;
65
}
66
67
export interface BeforeAuxiliaryWindowUnloadEvent {
68
veto(reason: string | undefined): void;
69
}
70
71
export interface IAuxiliaryWindow extends IDisposable {
72
73
readonly onWillLayout: Event<Dimension>;
74
readonly onDidLayout: Event<Dimension>;
75
76
readonly onBeforeUnload: Event<BeforeAuxiliaryWindowUnloadEvent>;
77
readonly onUnload: Event<void>;
78
79
readonly whenStylesHaveLoaded: Promise<void>;
80
81
readonly window: CodeWindow;
82
readonly container: HTMLElement;
83
84
updateOptions(options: { compact: boolean } | undefined): void;
85
86
layout(): void;
87
88
createState(): IAuxiliaryWindowOpenOptions;
89
}
90
91
const DEFAULT_AUX_WINDOW_DIMENSIONS = new Dimension(DEFAULT_AUX_WINDOW_SIZE.width, DEFAULT_AUX_WINDOW_SIZE.height);
92
93
export class AuxiliaryWindow extends BaseWindow implements IAuxiliaryWindow {
94
95
private readonly _onWillLayout = this._register(new Emitter<Dimension>());
96
readonly onWillLayout = this._onWillLayout.event;
97
98
private readonly _onDidLayout = this._register(new Emitter<Dimension>());
99
readonly onDidLayout = this._onDidLayout.event;
100
101
private readonly _onBeforeUnload = this._register(new Emitter<BeforeAuxiliaryWindowUnloadEvent>());
102
readonly onBeforeUnload = this._onBeforeUnload.event;
103
104
private readonly _onUnload = this._register(new Emitter<void>());
105
readonly onUnload = this._onUnload.event;
106
107
private readonly _onWillDispose = this._register(new Emitter<void>());
108
readonly onWillDispose = this._onWillDispose.event;
109
110
readonly whenStylesHaveLoaded: Promise<void>;
111
112
private compact = false;
113
114
constructor(
115
readonly window: CodeWindow,
116
readonly container: HTMLElement,
117
stylesHaveLoaded: Barrier,
118
@IConfigurationService private readonly configurationService: IConfigurationService,
119
@IHostService hostService: IHostService,
120
@IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService
121
) {
122
super(window, undefined, hostService, environmentService);
123
124
this.whenStylesHaveLoaded = stylesHaveLoaded.wait().then(() => undefined);
125
126
this.registerListeners();
127
}
128
129
updateOptions(options: { compact: boolean }): void {
130
this.compact = options.compact;
131
}
132
133
private registerListeners(): void {
134
this._register(addDisposableListener(this.window, EventType.BEFORE_UNLOAD, (e: BeforeUnloadEvent) => this.handleBeforeUnload(e)));
135
this._register(addDisposableListener(this.window, EventType.UNLOAD, () => this.handleUnload()));
136
137
this._register(addDisposableListener(this.window, 'unhandledrejection', e => {
138
onUnexpectedError(e.reason);
139
e.preventDefault();
140
}));
141
142
this._register(addDisposableListener(this.window, EventType.RESIZE, () => this.layout()));
143
144
this._register(addDisposableListener(this.container, EventType.SCROLL, () => this.container.scrollTop = 0)); // Prevent container from scrolling (#55456)
145
146
if (isWeb) {
147
this._register(addDisposableListener(this.container, EventType.DROP, e => EventHelper.stop(e, true))); // Prevent default navigation on drop
148
this._register(addDisposableListener(this.container, EventType.WHEEL, e => e.preventDefault(), { passive: false })); // Prevent the back/forward gestures in macOS
149
this._register(addDisposableListener(this.container, EventType.CONTEXT_MENU, e => EventHelper.stop(e, true))); // Prevent native context menus in web
150
} else {
151
this._register(addDisposableListener(this.window.document.body, EventType.DRAG_OVER, (e: DragEvent) => EventHelper.stop(e))); // Prevent drag feedback on <body>
152
this._register(addDisposableListener(this.window.document.body, EventType.DROP, (e: DragEvent) => EventHelper.stop(e))); // Prevent default navigation on drop
153
}
154
}
155
156
private handleBeforeUnload(e: BeforeUnloadEvent): void {
157
158
// Check for veto from a listening component
159
let veto: string | undefined;
160
this._onBeforeUnload.fire({
161
veto(reason) {
162
if (reason) {
163
veto = reason;
164
}
165
}
166
});
167
if (veto) {
168
this.handleVetoBeforeClose(e, veto);
169
170
return;
171
}
172
173
// Check for confirm before close setting
174
const confirmBeforeCloseSetting = this.configurationService.getValue<'always' | 'never' | 'keyboardOnly'>('window.confirmBeforeClose');
175
const confirmBeforeClose = confirmBeforeCloseSetting === 'always' || (confirmBeforeCloseSetting === 'keyboardOnly' && ModifierKeyEmitter.getInstance().isModifierPressed);
176
if (confirmBeforeClose) {
177
this.confirmBeforeClose(e);
178
}
179
}
180
181
protected handleVetoBeforeClose(e: BeforeUnloadEvent, reason: string): void {
182
this.preventUnload(e);
183
}
184
185
protected preventUnload(e: BeforeUnloadEvent): void {
186
e.preventDefault();
187
e.returnValue = localize('lifecycleVeto', "Changes that you made may not be saved. Please check press 'Cancel' and try again.");
188
}
189
190
protected confirmBeforeClose(e: BeforeUnloadEvent): void {
191
this.preventUnload(e);
192
}
193
194
private handleUnload(): void {
195
196
// Event
197
this._onUnload.fire();
198
}
199
200
layout(): void {
201
202
// Split layout up into two events so that downstream components
203
// have a chance to participate in the beginning or end of the
204
// layout phase.
205
// This helps to build the auxiliary window in another component
206
// in the `onWillLayout` phase and then let other compoments
207
// react when the overall layout has finished in `onDidLayout`.
208
209
const dimension = getClientArea(this.window.document.body, DEFAULT_AUX_WINDOW_DIMENSIONS, this.container);
210
this._onWillLayout.fire(dimension);
211
this._onDidLayout.fire(dimension);
212
}
213
214
createState(): IAuxiliaryWindowOpenOptions {
215
return {
216
bounds: {
217
x: this.window.screenX,
218
y: this.window.screenY,
219
width: this.window.outerWidth,
220
height: this.window.outerHeight
221
},
222
zoomLevel: getZoomLevel(this.window),
223
compact: this.compact
224
};
225
}
226
227
override dispose(): void {
228
if (this._store.isDisposed) {
229
return;
230
}
231
232
this._onWillDispose.fire();
233
234
super.dispose();
235
}
236
}
237
238
export class BrowserAuxiliaryWindowService extends Disposable implements IAuxiliaryWindowService {
239
240
declare readonly _serviceBrand: undefined;
241
242
private static WINDOW_IDS = getWindowId(mainWindow) + 1; // start from the main window ID + 1
243
244
private readonly _onDidOpenAuxiliaryWindow = this._register(new Emitter<IAuxiliaryWindowOpenEvent>());
245
readonly onDidOpenAuxiliaryWindow = this._onDidOpenAuxiliaryWindow.event;
246
247
private readonly windows = new Map<number, IAuxiliaryWindow>();
248
249
constructor(
250
@IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService,
251
@IDialogService protected readonly dialogService: IDialogService,
252
@IConfigurationService protected readonly configurationService: IConfigurationService,
253
@ITelemetryService private readonly telemetryService: ITelemetryService,
254
@IHostService protected readonly hostService: IHostService,
255
@IWorkbenchEnvironmentService protected readonly environmentService: IWorkbenchEnvironmentService
256
) {
257
super();
258
}
259
260
async open(options?: IAuxiliaryWindowOpenOptions): Promise<IAuxiliaryWindow> {
261
mark('code/auxiliaryWindow/willOpen');
262
263
const targetWindow = await this.openWindow(options);
264
if (!targetWindow) {
265
throw new Error(localize('unableToOpenWindowError', "Unable to open a new window."));
266
}
267
268
// Add a `vscodeWindowId` property to identify auxiliary windows
269
const resolvedWindowId = await this.resolveWindowId(targetWindow);
270
ensureCodeWindow(targetWindow, resolvedWindowId);
271
272
const containerDisposables = new DisposableStore();
273
const { container, stylesLoaded } = this.createContainer(targetWindow, containerDisposables, options);
274
275
const auxiliaryWindow = this.createAuxiliaryWindow(targetWindow, container, stylesLoaded);
276
auxiliaryWindow.updateOptions({ compact: options?.compact ?? false });
277
278
const registryDisposables = new DisposableStore();
279
this.windows.set(targetWindow.vscodeWindowId, auxiliaryWindow);
280
registryDisposables.add(toDisposable(() => this.windows.delete(targetWindow.vscodeWindowId)));
281
282
const eventDisposables = new DisposableStore();
283
284
Event.once(auxiliaryWindow.onWillDispose)(() => {
285
targetWindow.close();
286
287
containerDisposables.dispose();
288
registryDisposables.dispose();
289
eventDisposables.dispose();
290
});
291
292
registryDisposables.add(registerWindow(targetWindow));
293
this._onDidOpenAuxiliaryWindow.fire({ window: auxiliaryWindow, disposables: eventDisposables });
294
295
mark('code/auxiliaryWindow/didOpen');
296
297
type AuxiliaryWindowClassification = {
298
owner: 'bpasero';
299
comment: 'An event that fires when an auxiliary window is opened';
300
bounds: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Has window bounds provided.' };
301
};
302
type AuxiliaryWindowOpenEvent = {
303
bounds: boolean;
304
};
305
this.telemetryService.publicLog2<AuxiliaryWindowOpenEvent, AuxiliaryWindowClassification>('auxiliaryWindowOpen', { bounds: !!options?.bounds });
306
307
return auxiliaryWindow;
308
}
309
310
protected createAuxiliaryWindow(targetWindow: CodeWindow, container: HTMLElement, stylesLoaded: Barrier): AuxiliaryWindow {
311
return new AuxiliaryWindow(targetWindow, container, stylesLoaded, this.configurationService, this.hostService, this.environmentService);
312
}
313
314
private async openWindow(options?: IAuxiliaryWindowOpenOptions): Promise<Window | undefined> {
315
const activeWindow = getActiveWindow();
316
const activeWindowBounds = {
317
x: activeWindow.screenX,
318
y: activeWindow.screenY,
319
width: activeWindow.outerWidth,
320
height: activeWindow.outerHeight
321
};
322
323
const defaultSize = DEFAULT_AUX_WINDOW_SIZE;
324
325
const width = Math.max(options?.bounds?.width ?? defaultSize.width, WindowMinimumSize.WIDTH);
326
const height = Math.max(options?.bounds?.height ?? defaultSize.height, WindowMinimumSize.HEIGHT);
327
328
let newWindowBounds: IRectangle = {
329
x: options?.bounds?.x ?? Math.max(activeWindowBounds.x + activeWindowBounds.width / 2 - width / 2, 0),
330
y: options?.bounds?.y ?? Math.max(activeWindowBounds.y + activeWindowBounds.height / 2 - height / 2, 0),
331
width,
332
height
333
};
334
335
if (!options?.bounds && newWindowBounds.x === activeWindowBounds.x && newWindowBounds.y === activeWindowBounds.y) {
336
// Offset the new window a bit so that it does not overlap
337
// with the active window, unless bounds are provided
338
newWindowBounds = {
339
...newWindowBounds,
340
x: newWindowBounds.x + 30,
341
y: newWindowBounds.y + 30
342
};
343
}
344
345
const features = coalesce([
346
'popup=yes',
347
`left=${newWindowBounds.x}`,
348
`top=${newWindowBounds.y}`,
349
`width=${newWindowBounds.width}`,
350
`height=${newWindowBounds.height}`,
351
352
// non-standard properties
353
options?.nativeTitlebar ? 'window-native-titlebar=yes' : undefined,
354
options?.disableFullscreen ? 'window-disable-fullscreen=yes' : undefined,
355
options?.alwaysOnTop ? 'window-always-on-top=yes' : undefined,
356
options?.mode === AuxiliaryWindowMode.Maximized ? 'window-maximized=yes' : undefined,
357
options?.mode === AuxiliaryWindowMode.Fullscreen ? 'window-fullscreen=yes' : undefined
358
]);
359
360
const auxiliaryWindow = mainWindow.open(isFirefox ? '' /* FF immediately fires an unload event if using about:blank */ : 'about:blank', undefined, features.join(','));
361
if (!auxiliaryWindow && isWeb) {
362
return (await this.dialogService.prompt({
363
type: Severity.Warning,
364
message: localize('unableToOpenWindow', "The browser blocked opening a new window. Press 'Retry' to try again."),
365
custom: {
366
markdownDetails: [{ markdown: new MarkdownString(localize('unableToOpenWindowDetail', "Please allow pop-ups for this website in your [browser settings]({0}).", 'https://aka.ms/allow-vscode-popup'), true) }]
367
},
368
buttons: [
369
{
370
label: localize({ key: 'retry', comment: ['&& denotes a mnemonic'] }, "&&Retry"),
371
run: () => this.openWindow(options)
372
}
373
],
374
cancelButton: true
375
})).result;
376
}
377
378
return auxiliaryWindow?.window;
379
}
380
381
protected async resolveWindowId(auxiliaryWindow: Window): Promise<number> {
382
return BrowserAuxiliaryWindowService.WINDOW_IDS++;
383
}
384
385
protected createContainer(auxiliaryWindow: CodeWindow, disposables: DisposableStore, options?: IAuxiliaryWindowOpenOptions): { stylesLoaded: Barrier; container: HTMLElement } {
386
auxiliaryWindow.document.createElement = function () {
387
// Disallow `createElement` because it would create
388
// HTML Elements in the "wrong" context and break
389
// code that does "instanceof HTMLElement" etc.
390
throw 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.');
391
};
392
393
this.applyMeta(auxiliaryWindow);
394
const { stylesLoaded } = this.applyCSS(auxiliaryWindow, disposables);
395
const container = this.applyHTML(auxiliaryWindow, disposables);
396
397
return { stylesLoaded, container };
398
}
399
400
private applyMeta(auxiliaryWindow: CodeWindow): void {
401
for (const metaTag of ['meta[charset="utf-8"]', 'meta[http-equiv="Content-Security-Policy"]', 'meta[name="viewport"]', 'meta[name="theme-color"]']) {
402
const metaElement = mainWindow.document.querySelector(metaTag);
403
if (metaElement) {
404
const clonedMetaElement = createMetaElement(auxiliaryWindow.document.head);
405
copyAttributes(metaElement, clonedMetaElement);
406
407
if (metaTag === 'meta[http-equiv="Content-Security-Policy"]') {
408
const content = clonedMetaElement.getAttribute('content');
409
if (content) {
410
clonedMetaElement.setAttribute('content', content.replace(/(script-src[^\;]*)/, `script-src 'none'`));
411
}
412
}
413
}
414
}
415
416
const originalIconLinkTag = mainWindow.document.querySelector('link[rel="icon"]');
417
if (originalIconLinkTag) {
418
const icon = createLinkElement(auxiliaryWindow.document.head);
419
copyAttributes(originalIconLinkTag, icon);
420
}
421
}
422
423
private applyCSS(auxiliaryWindow: CodeWindow, disposables: DisposableStore) {
424
mark('code/auxiliaryWindow/willApplyCSS');
425
426
const mapOriginalToClone = new Map<Node /* original */, Node /* clone */>();
427
428
const stylesLoaded = new Barrier();
429
stylesLoaded.wait().then(() => mark('code/auxiliaryWindow/didLoadCSSStyles'));
430
431
const pendingLinksDisposables = disposables.add(new DisposableStore());
432
433
let pendingLinksToSettle = 0;
434
function onLinkSettled() {
435
if (--pendingLinksToSettle === 0) {
436
pendingLinksDisposables.dispose();
437
stylesLoaded.open();
438
}
439
}
440
441
function cloneNode(originalNode: Element): void {
442
if (isGlobalStylesheet(originalNode)) {
443
return; // global stylesheets are handled by `cloneGlobalStylesheets` below
444
}
445
446
const clonedNode = auxiliaryWindow.document.head.appendChild(originalNode.cloneNode(true));
447
if (originalNode.tagName.toLowerCase() === 'link') {
448
pendingLinksToSettle++;
449
450
pendingLinksDisposables.add(addDisposableListener(clonedNode, 'load', onLinkSettled));
451
pendingLinksDisposables.add(addDisposableListener(clonedNode, 'error', onLinkSettled));
452
}
453
454
mapOriginalToClone.set(originalNode, clonedNode);
455
}
456
457
// Clone all style elements and stylesheet links from the window to the child window
458
// and keep track of <link> elements to settle to signal that styles have loaded
459
// Increment pending links right from the beginning to ensure we only settle when
460
// all style related nodes have been cloned.
461
pendingLinksToSettle++;
462
try {
463
for (const originalNode of mainWindow.document.head.querySelectorAll('link[rel="stylesheet"], style')) {
464
cloneNode(originalNode);
465
}
466
} finally {
467
onLinkSettled();
468
}
469
470
// Global stylesheets in <head> are cloned in a special way because the mutation
471
// observer is not firing for changes done via `style.sheet` API. Only text changes
472
// can be observed.
473
disposables.add(cloneGlobalStylesheets(auxiliaryWindow));
474
475
// Listen to new stylesheets as they are being added or removed in the main window
476
// and apply to child window (including changes to existing stylesheets elements)
477
disposables.add(sharedMutationObserver.observe(mainWindow.document.head, disposables, { childList: true, subtree: true })(mutations => {
478
for (const mutation of mutations) {
479
if (
480
mutation.type !== 'childList' || // only interested in added/removed nodes
481
mutation.target.nodeName.toLowerCase() === 'title' || // skip over title changes that happen frequently
482
mutation.target.nodeName.toLowerCase() === 'script' || // block <script> changes that are unsupported anyway
483
mutation.target.nodeName.toLowerCase() === 'meta' // do not observe <meta> elements for now
484
) {
485
continue;
486
}
487
488
for (const node of mutation.addedNodes) {
489
490
// <style>/<link> element was added
491
if (isHTMLElement(node) && (node.tagName.toLowerCase() === 'style' || node.tagName.toLowerCase() === 'link')) {
492
cloneNode(node);
493
}
494
495
// text-node was changed, try to apply to our clones
496
else if (node.nodeType === Node.TEXT_NODE && node.parentNode) {
497
const clonedNode = mapOriginalToClone.get(node.parentNode);
498
if (clonedNode) {
499
clonedNode.textContent = node.textContent;
500
}
501
}
502
}
503
504
for (const node of mutation.removedNodes) {
505
const clonedNode = mapOriginalToClone.get(node);
506
if (clonedNode) {
507
clonedNode.parentNode?.removeChild(clonedNode);
508
mapOriginalToClone.delete(node);
509
}
510
}
511
}
512
}));
513
514
mark('code/auxiliaryWindow/didApplyCSS');
515
516
return { stylesLoaded };
517
}
518
519
private applyHTML(auxiliaryWindow: CodeWindow, disposables: DisposableStore): HTMLElement {
520
mark('code/auxiliaryWindow/willApplyHTML');
521
522
// Create workbench container and apply classes
523
const container = $('div', { role: 'application' });
524
position(container, 0, 0, 0, 0, 'relative');
525
container.style.display = 'flex';
526
container.style.height = '100%';
527
container.style.flexDirection = 'column';
528
auxiliaryWindow.document.body.append(container);
529
530
// Track attributes
531
disposables.add(trackAttributes(mainWindow.document.documentElement, auxiliaryWindow.document.documentElement));
532
disposables.add(trackAttributes(mainWindow.document.body, auxiliaryWindow.document.body));
533
disposables.add(trackAttributes(this.layoutService.mainContainer, container, ['class'])); // only class attribute
534
535
mark('code/auxiliaryWindow/didApplyHTML');
536
537
return container;
538
}
539
540
getWindow(windowId: number): IAuxiliaryWindow | undefined {
541
return this.windows.get(windowId);
542
}
543
}
544
545
registerSingleton(IAuxiliaryWindowService, BrowserAuxiliaryWindowService, InstantiationType.Delayed);
546
547