Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts
4780 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 './media/browser.css';
7
import { localize } from '../../../../nls.js';
8
import { $, addDisposableListener, disposableWindowInterval, EventType, scheduleAtNextAnimationFrame } from '../../../../base/browser/dom.js';
9
import { CancellationToken } from '../../../../base/common/cancellation.js';
10
import { RawContextKey, IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';
11
import { MenuId } from '../../../../platform/actions/common/actions.js';
12
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
13
import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js';
14
import { IEditorService } from '../../../services/editor/common/editorService.js';
15
import { EditorPane } from '../../../browser/parts/editor/editorPane.js';
16
import { IEditorOpenContext } from '../../../common/editor.js';
17
import { BrowserEditorInput } from './browserEditorInput.js';
18
import { BrowserViewUri } from '../../../../platform/browserView/common/browserViewUri.js';
19
import { IBrowserViewModel } from '../../browserView/common/browserView.js';
20
import { IThemeService } from '../../../../platform/theme/common/themeService.js';
21
import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js';
22
import { IStorageService } from '../../../../platform/storage/common/storage.js';
23
import { IBrowserViewKeyDownEvent, IBrowserViewNavigationEvent, IBrowserViewLoadError } from '../../../../platform/browserView/common/browserView.js';
24
import { IEditorGroup } from '../../../services/editor/common/editorGroupsService.js';
25
import { IEditorOptions } from '../../../../platform/editor/common/editor.js';
26
import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js';
27
import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js';
28
import { BrowserOverlayManager } from './overlayManager.js';
29
import { getZoomFactor, onDidChangeZoomLevel } from '../../../../base/browser/browser.js';
30
import { ILogService } from '../../../../platform/log/common/log.js';
31
import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js';
32
import { WorkbenchHoverDelegate } from '../../../../platform/hover/browser/hover.js';
33
import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js';
34
import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js';
35
import { encodeBase64, VSBuffer } from '../../../../base/common/buffer.js';
36
37
export const CONTEXT_BROWSER_CAN_GO_BACK = new RawContextKey<boolean>('browserCanGoBack', false, localize('browser.canGoBack', "Whether the browser can go back"));
38
export const CONTEXT_BROWSER_CAN_GO_FORWARD = new RawContextKey<boolean>('browserCanGoForward', false, localize('browser.canGoForward', "Whether the browser can go forward"));
39
export const CONTEXT_BROWSER_FOCUSED = new RawContextKey<boolean>('browserFocused', true, localize('browser.editorFocused', "Whether the browser editor is focused"));
40
export const CONTEXT_BROWSER_STORAGE_SCOPE = new RawContextKey<string>('browserStorageScope', '', localize('browser.storageScope', "The storage scope of the current browser view"));
41
export const CONTEXT_BROWSER_DEVTOOLS_OPEN = new RawContextKey<boolean>('browserDevToolsOpen', false, localize('browser.devToolsOpen', "Whether developer tools are open for the current browser view"));
42
43
class BrowserNavigationBar extends Disposable {
44
private readonly _urlInput: HTMLInputElement;
45
46
constructor(
47
editor: BrowserEditor,
48
container: HTMLElement,
49
instantiationService: IInstantiationService,
50
scopedContextKeyService: IContextKeyService
51
) {
52
super();
53
54
// Create hover delegate for toolbar buttons
55
const hoverDelegate = this._register(
56
instantiationService.createInstance(
57
WorkbenchHoverDelegate,
58
'element',
59
undefined,
60
{ position: { hoverPosition: HoverPosition.ABOVE } }
61
)
62
);
63
64
// Create navigation toolbar (left side) with scoped context
65
const navContainer = $('.browser-nav-toolbar');
66
const scopedInstantiationService = instantiationService.createChild(new ServiceCollection(
67
[IContextKeyService, scopedContextKeyService]
68
));
69
const navToolbar = this._register(scopedInstantiationService.createInstance(
70
MenuWorkbenchToolBar,
71
navContainer,
72
MenuId.BrowserNavigationToolbar,
73
{
74
hoverDelegate,
75
highlightToggledItems: true,
76
// Render all actions inline regardless of group
77
toolbarOptions: { primaryGroup: () => true, useSeparatorsInPrimaryActions: true },
78
menuOptions: { shouldForwardArgs: true }
79
}
80
));
81
82
// URL input
83
this._urlInput = $<HTMLInputElement>('input.browser-url-input');
84
this._urlInput.type = 'text';
85
this._urlInput.placeholder = localize('browser.urlPlaceholder', "Enter URL...");
86
87
// Create actions toolbar (right side) with scoped context
88
const actionsContainer = $('.browser-actions-toolbar');
89
const actionsToolbar = this._register(scopedInstantiationService.createInstance(
90
MenuWorkbenchToolBar,
91
actionsContainer,
92
MenuId.BrowserActionsToolbar,
93
{
94
hoverDelegate,
95
highlightToggledItems: true,
96
toolbarOptions: { primaryGroup: 'actions' },
97
menuOptions: { shouldForwardArgs: true }
98
}
99
));
100
101
navToolbar.context = editor;
102
actionsToolbar.context = editor;
103
104
// Assemble layout: nav | url | actions
105
container.appendChild(navContainer);
106
container.appendChild(this._urlInput);
107
container.appendChild(actionsContainer);
108
109
// Setup URL input handler
110
this._register(addDisposableListener(this._urlInput, EventType.KEY_DOWN, (e: KeyboardEvent) => {
111
if (e.key === 'Enter') {
112
const url = this._urlInput.value.trim();
113
if (url) {
114
editor.navigateToUrl(url);
115
}
116
}
117
}));
118
}
119
120
/**
121
* Update the navigation bar state from a navigation event
122
*/
123
updateFromNavigationEvent(event: IBrowserViewNavigationEvent): void {
124
// URL input is updated, action enablement is handled by context keys
125
this._urlInput.value = event.url;
126
}
127
128
/**
129
* Focus the URL input and select all text
130
*/
131
focusUrlInput(): void {
132
this._urlInput.select();
133
this._urlInput.focus();
134
}
135
136
clear(): void {
137
this._urlInput.value = '';
138
}
139
}
140
141
export class BrowserEditor extends EditorPane {
142
static readonly ID = 'workbench.editor.browser';
143
144
private _overlayVisible = false;
145
private _editorVisible = false;
146
private _currentKeyDownEvent: IBrowserViewKeyDownEvent | undefined;
147
148
private _navigationBar!: BrowserNavigationBar;
149
private _browserContainer!: HTMLElement;
150
private _errorContainer!: HTMLElement;
151
private _canGoBackContext!: IContextKey<boolean>;
152
private _canGoForwardContext!: IContextKey<boolean>;
153
private _storageScopeContext!: IContextKey<string>;
154
private _devToolsOpenContext!: IContextKey<boolean>;
155
156
private _model: IBrowserViewModel | undefined;
157
private readonly _inputDisposables = this._register(new DisposableStore());
158
private overlayManager: BrowserOverlayManager | undefined;
159
160
constructor(
161
group: IEditorGroup,
162
@ITelemetryService telemetryService: ITelemetryService,
163
@IThemeService themeService: IThemeService,
164
@IStorageService storageService: IStorageService,
165
@IKeybindingService private readonly keybindingService: IKeybindingService,
166
@ILogService private readonly logService: ILogService,
167
@IInstantiationService private readonly instantiationService: IInstantiationService,
168
@IContextKeyService private readonly contextKeyService: IContextKeyService,
169
@IEditorService private readonly editorService: IEditorService
170
) {
171
super(BrowserEditor.ID, group, telemetryService, themeService, storageService);
172
}
173
174
protected override createEditor(parent: HTMLElement): void {
175
// Create scoped context key service for this editor instance
176
const contextKeyService = this._register(this.contextKeyService.createScoped(parent));
177
178
// Create window-specific overlay manager for this editor
179
this.overlayManager = this._register(new BrowserOverlayManager(this.window));
180
181
// Bind navigation capability context keys
182
this._canGoBackContext = CONTEXT_BROWSER_CAN_GO_BACK.bindTo(contextKeyService);
183
this._canGoForwardContext = CONTEXT_BROWSER_CAN_GO_FORWARD.bindTo(contextKeyService);
184
this._storageScopeContext = CONTEXT_BROWSER_STORAGE_SCOPE.bindTo(contextKeyService);
185
this._devToolsOpenContext = CONTEXT_BROWSER_DEVTOOLS_OPEN.bindTo(contextKeyService);
186
187
// Currently this is always true since it is scoped to the editor container
188
CONTEXT_BROWSER_FOCUSED.bindTo(contextKeyService);
189
190
// Create root container
191
const root = $('.browser-root');
192
parent.appendChild(root);
193
194
// Create toolbar with navigation buttons and URL input
195
const toolbar = $('.browser-toolbar');
196
197
// Create navigation bar widget with scoped context
198
this._navigationBar = this._register(new BrowserNavigationBar(this, toolbar, this.instantiationService, contextKeyService));
199
200
root.appendChild(toolbar);
201
202
// Create browser container (stub element for positioning)
203
this._browserContainer = $('.browser-container');
204
this._browserContainer.tabIndex = 0; // make focusable
205
root.appendChild(this._browserContainer);
206
207
// Create error container (hidden by default)
208
this._errorContainer = $('.browser-error-container');
209
this._errorContainer.style.display = 'none';
210
this._browserContainer.appendChild(this._errorContainer);
211
212
this._register(addDisposableListener(this._browserContainer, EventType.FOCUS, (event) => {
213
// When the browser container gets focus, make sure the browser view also gets focused.
214
// But only if focus was already in the workbench (and not e.g. clicking back into the workbench from the browser view).
215
if (event.relatedTarget && this._model && this.shouldShowView) {
216
void this._model.focus();
217
}
218
}));
219
220
this._register(addDisposableListener(this._browserContainer, EventType.BLUR, () => {
221
// When focus goes to another part of the workbench, make sure the workbench view becomes focused.
222
const focused = this.window.document.activeElement;
223
if (focused && focused !== this._browserContainer) {
224
this.window.focus();
225
}
226
}));
227
}
228
229
override async setInput(input: BrowserEditorInput, options: IEditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise<void> {
230
await super.setInput(input, options, context, token);
231
if (token.isCancellationRequested) {
232
return;
233
}
234
235
this._inputDisposables.clear();
236
237
// Resolve the browser view model from the input
238
this._model = await input.resolve();
239
if (token.isCancellationRequested || this.input !== input) {
240
return;
241
}
242
243
this._storageScopeContext.set(this._model.storageScope);
244
this._devToolsOpenContext.set(this._model.isDevToolsOpen);
245
246
// Clean up on input disposal
247
this._inputDisposables.add(input.onWillDispose(() => {
248
this._model = undefined;
249
}));
250
251
// Initialize UI state and context keys from model
252
this.updateNavigationState({
253
url: this._model.url,
254
canGoBack: this._model.canGoBack,
255
canGoForward: this._model.canGoForward
256
});
257
this.setBackgroundImage(this._model.screenshot);
258
259
if (context.newInGroup) {
260
this._navigationBar.focusUrlInput();
261
}
262
263
// Listen to model events for UI updates
264
this._inputDisposables.add(this._model.onDidKeyCommand(keyEvent => {
265
// Handle like webview does - convert to webview KeyEvent format
266
this.handleKeyEventFromBrowserView(keyEvent);
267
}));
268
269
this._inputDisposables.add(this._model.onDidNavigate((navEvent: IBrowserViewNavigationEvent) => {
270
this.group.pinEditor(this.input); // pin editor on navigation
271
272
// Update navigation bar and context keys from model
273
this.updateNavigationState(navEvent);
274
}));
275
276
this._inputDisposables.add(this._model.onDidChangeLoadingState(() => {
277
this.updateErrorDisplay();
278
}));
279
280
this._inputDisposables.add(this._model.onDidChangeFocus(({ focused }) => {
281
// When the view gets focused, make sure the container also has focus.
282
if (focused) {
283
this._browserContainer.focus();
284
}
285
}));
286
287
this._inputDisposables.add(this._model.onDidChangeDevToolsState(e => {
288
this._devToolsOpenContext.set(e.isDevToolsOpen);
289
}));
290
291
this._inputDisposables.add(this._model.onDidRequestNewPage(({ url, name, background }) => {
292
type IntegratedBrowserNewPageRequestEvent = {
293
background: boolean;
294
};
295
296
type IntegratedBrowserNewPageRequestClassification = {
297
background: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether page was requested to open in background' };
298
owner: 'kycutler';
299
comment: 'Tracks new page requests from integrated browser';
300
};
301
302
this.telemetryService.publicLog2<IntegratedBrowserNewPageRequestEvent, IntegratedBrowserNewPageRequestClassification>(
303
'integratedBrowser.newPageRequest',
304
{
305
background
306
}
307
);
308
309
// Open a new browser tab for the requested URL
310
const browserUri = BrowserViewUri.forUrl(url, name ? `${input.id}-${name}` : undefined);
311
this.editorService.openEditor({
312
resource: browserUri,
313
options: {
314
pinned: true,
315
inactive: background
316
}
317
}, this.group);
318
}));
319
320
this._inputDisposables.add(this.overlayManager!.onDidChangeOverlayState(() => {
321
this.checkOverlays();
322
}));
323
324
// Listen for zoom level changes and update browser view zoom factor
325
this._inputDisposables.add(onDidChangeZoomLevel(targetWindowId => {
326
if (targetWindowId === this.window.vscodeWindowId) {
327
this.layout();
328
}
329
}));
330
// Capture screenshot periodically (once per second) to keep background updated
331
this._inputDisposables.add(disposableWindowInterval(
332
this.window,
333
() => this.capturePlaceholderSnapshot(),
334
1000
335
));
336
337
this.updateErrorDisplay();
338
this.layout();
339
await this._model.setVisible(this.shouldShowView);
340
341
// Sometimes the element has not been inserted into the DOM yet. Ensure layout after next animation frame.
342
scheduleAtNextAnimationFrame(this.window, () => this.layout());
343
}
344
345
protected override setEditorVisible(visible: boolean): void {
346
this._editorVisible = visible;
347
this.updateVisibility();
348
}
349
350
private updateVisibility(): void {
351
if (this._model) {
352
// Blur the background image if the view is hidden due to an overlay.
353
this._browserContainer.classList.toggle('blur', this._editorVisible && this._overlayVisible && !this._model?.error);
354
void this._model.setVisible(this.shouldShowView);
355
}
356
}
357
358
private get shouldShowView(): boolean {
359
return this._editorVisible && !this._overlayVisible && !this._model?.error;
360
}
361
362
private checkOverlays(): void {
363
if (!this.overlayManager) {
364
return;
365
}
366
const hasOverlappingOverlay = this.overlayManager.isOverlappingWithOverlays(this._browserContainer);
367
if (hasOverlappingOverlay !== this._overlayVisible) {
368
this._overlayVisible = hasOverlappingOverlay;
369
this.updateVisibility();
370
}
371
}
372
373
private updateErrorDisplay(): void {
374
if (!this._model) {
375
return;
376
}
377
378
const error: IBrowserViewLoadError | undefined = this._model.error;
379
if (error) {
380
// Show error display
381
this._errorContainer.style.display = 'flex';
382
383
while (this._errorContainer.firstChild) {
384
this._errorContainer.removeChild(this._errorContainer.firstChild);
385
}
386
387
const errorContent = $('.browser-error-content');
388
const errorTitle = $('.browser-error-title');
389
errorTitle.textContent = localize('browser.loadErrorLabel', "Failed to Load Page");
390
391
const errorMessage = $('.browser-error-detail');
392
const errorText = $('span');
393
errorText.textContent = `${error.errorDescription} (${error.errorCode})`;
394
errorMessage.appendChild(errorText);
395
396
const errorUrl = $('.browser-error-detail');
397
const urlLabel = $('strong');
398
urlLabel.textContent = localize('browser.errorUrlLabel', "URL:");
399
const urlValue = $('code');
400
urlValue.textContent = error.url;
401
errorUrl.appendChild(urlLabel);
402
errorUrl.appendChild(document.createTextNode(' '));
403
errorUrl.appendChild(urlValue);
404
405
errorContent.appendChild(errorTitle);
406
errorContent.appendChild(errorMessage);
407
errorContent.appendChild(errorUrl);
408
this._errorContainer.appendChild(errorContent);
409
410
this.setBackgroundImage(undefined);
411
} else {
412
// Hide error display
413
this._errorContainer.style.display = 'none';
414
this.setBackgroundImage(this._model.screenshot);
415
}
416
417
this.updateVisibility();
418
}
419
420
async navigateToUrl(url: string): Promise<void> {
421
if (this._model) {
422
this.group.pinEditor(this.input); // pin editor on navigation
423
424
const scheme = URL.parse(url)?.protocol;
425
if (!scheme) {
426
// If no scheme provided, default to http (to support localhost etc -- sites will generally upgrade to https)
427
url = 'http://' + url;
428
}
429
430
await this._model.loadURL(url);
431
}
432
}
433
434
async goBack(): Promise<void> {
435
return this._model?.goBack();
436
}
437
438
async goForward(): Promise<void> {
439
return this._model?.goForward();
440
}
441
442
async reload(): Promise<void> {
443
return this._model?.reload();
444
}
445
446
async toggleDevTools(): Promise<void> {
447
return this._model?.toggleDevTools();
448
}
449
450
/**
451
* Update navigation state and context keys
452
*/
453
private updateNavigationState(event: IBrowserViewNavigationEvent): void {
454
// Update navigation bar UI
455
this._navigationBar.updateFromNavigationEvent(event);
456
457
// Update context keys for command enablement
458
this._canGoBackContext.set(event.canGoBack);
459
this._canGoForwardContext.set(event.canGoForward);
460
}
461
462
private setBackgroundImage(buffer: VSBuffer | undefined): void {
463
if (buffer) {
464
const dataUrl = `data:image/jpeg;base64,${encodeBase64(buffer)}`;
465
this._browserContainer.style.backgroundImage = `url('${dataUrl}')`;
466
} else {
467
this._browserContainer.style.backgroundImage = '';
468
}
469
}
470
471
/**
472
* Capture a screenshot of the current browser view to use as placeholder background
473
*/
474
private async capturePlaceholderSnapshot(): Promise<void> {
475
if (this._model && !this._overlayVisible) {
476
try {
477
const buffer = await this._model.captureScreenshot({ quality: 80 });
478
this.setBackgroundImage(buffer);
479
} catch (error) {
480
this.logService.error('BrowserEditor.capturePlaceholderSnapshot: Failed to capture screenshot', error);
481
}
482
}
483
}
484
485
forwardCurrentEvent(): boolean {
486
if (this._currentKeyDownEvent && this._model) {
487
void this._model.dispatchKeyEvent(this._currentKeyDownEvent);
488
return true;
489
}
490
return false;
491
}
492
493
private async handleKeyEventFromBrowserView(keyEvent: IBrowserViewKeyDownEvent): Promise<void> {
494
this._currentKeyDownEvent = keyEvent;
495
496
try {
497
const syntheticEvent = new KeyboardEvent('keydown', keyEvent);
498
const standardEvent = new StandardKeyboardEvent(syntheticEvent);
499
500
const handled = this.keybindingService.dispatchEvent(standardEvent, this._browserContainer);
501
if (!handled) {
502
this.forwardCurrentEvent();
503
}
504
} catch (error) {
505
this.logService.error('BrowserEditor.handleKeyEventFromBrowserView: Error dispatching key event', error);
506
} finally {
507
this._currentKeyDownEvent = undefined;
508
}
509
}
510
511
override layout(): void {
512
if (this._model) {
513
this.checkOverlays();
514
515
const containerRect = this._browserContainer.getBoundingClientRect();
516
void this._model.layout({
517
windowId: this.group.windowId,
518
x: containerRect.left,
519
y: containerRect.top,
520
width: containerRect.width,
521
height: containerRect.height,
522
zoomFactor: getZoomFactor(this.window)
523
});
524
}
525
}
526
527
override clearInput(): void {
528
this._inputDisposables.clear();
529
530
void this._model?.setVisible(false);
531
this._model = undefined;
532
533
this._canGoBackContext.reset();
534
this._canGoForwardContext.reset();
535
this._storageScopeContext.reset();
536
this._devToolsOpenContext.reset();
537
538
this._navigationBar.clear();
539
this.setBackgroundImage(undefined);
540
541
super.clearInput();
542
}
543
}
544
545