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
5248 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, Dimension, EventType, IDomPosition, registerExternalFocusChecker } from '../../../../base/browser/dom.js';
9
import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js';
10
import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js';
11
import { RawContextKey, IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';
12
import { MenuId } from '../../../../platform/actions/common/actions.js';
13
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
14
import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js';
15
import { AUX_WINDOW_GROUP, IEditorService } from '../../../services/editor/common/editorService.js';
16
import { EditorPane } from '../../../browser/parts/editor/editorPane.js';
17
import { IEditorOpenContext } from '../../../common/editor.js';
18
import { BrowserEditorInput } from './browserEditorInput.js';
19
import { BrowserViewUri } from '../../../../platform/browserView/common/browserViewUri.js';
20
import { IBrowserViewModel } from '../../browserView/common/browserView.js';
21
import { IThemeService } from '../../../../platform/theme/common/themeService.js';
22
import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js';
23
import { IStorageService } from '../../../../platform/storage/common/storage.js';
24
import { IBrowserViewKeyDownEvent, IBrowserViewNavigationEvent, IBrowserViewLoadError, BrowserNewPageLocation } from '../../../../platform/browserView/common/browserView.js';
25
import { IEditorGroup } from '../../../services/editor/common/editorGroupsService.js';
26
import { IEditorOptions } from '../../../../platform/editor/common/editor.js';
27
import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js';
28
import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js';
29
import { BrowserOverlayManager, BrowserOverlayType, IBrowserOverlayInfo } from './overlayManager.js';
30
import { getZoomFactor, onDidChangeZoomLevel } from '../../../../base/browser/browser.js';
31
import { ILogService } from '../../../../platform/log/common/log.js';
32
import { Disposable, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js';
33
import { Lazy } from '../../../../base/common/lazy.js';
34
import { WorkbenchHoverDelegate } from '../../../../platform/hover/browser/hover.js';
35
import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js';
36
import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js';
37
import { IBrowserElementsService } from '../../../services/browserElements/browser/browserElementsService.js';
38
import { IChatWidgetService } from '../../chat/browser/chat.js';
39
import { ChatContextKeys } from '../../chat/common/actions/chatContextKeys.js';
40
import { BrowserFindWidget, CONTEXT_BROWSER_FIND_WIDGET_FOCUSED, CONTEXT_BROWSER_FIND_WIDGET_VISIBLE } from './browserFindWidget.js';
41
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
42
import { ThemeIcon } from '../../../../base/common/themables.js';
43
import { Codicon } from '../../../../base/common/codicons.js';
44
import { encodeBase64, VSBuffer } from '../../../../base/common/buffer.js';
45
import { IChatRequestVariableEntry } from '../../chat/common/attachments/chatVariableEntries.js';
46
import { IBrowserTargetLocator, getDisplayNameFromOuterHTML } from '../../../../platform/browserElements/common/browserElements.js';
47
import { logBrowserOpen } from './browserViewTelemetry.js';
48
import { URI } from '../../../../base/common/uri.js';
49
50
export const CONTEXT_BROWSER_CAN_GO_BACK = new RawContextKey<boolean>('browserCanGoBack', false, localize('browser.canGoBack', "Whether the browser can go back"));
51
export const CONTEXT_BROWSER_CAN_GO_FORWARD = new RawContextKey<boolean>('browserCanGoForward', false, localize('browser.canGoForward', "Whether the browser can go forward"));
52
export const CONTEXT_BROWSER_FOCUSED = new RawContextKey<boolean>('browserFocused', true, localize('browser.editorFocused', "Whether the browser editor is focused"));
53
export const CONTEXT_BROWSER_STORAGE_SCOPE = new RawContextKey<string>('browserStorageScope', '', localize('browser.storageScope', "The storage scope of the current browser view"));
54
export const CONTEXT_BROWSER_HAS_URL = new RawContextKey<boolean>('browserHasUrl', false, localize('browser.hasUrl', "Whether the browser has a URL loaded"));
55
export const CONTEXT_BROWSER_DEVTOOLS_OPEN = new RawContextKey<boolean>('browserDevToolsOpen', false, localize('browser.devToolsOpen', "Whether developer tools are open for the current browser view"));
56
export const CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE = new RawContextKey<boolean>('browserElementSelectionActive', false, localize('browser.elementSelectionActive', "Whether element selection is currently active"));
57
58
// Re-export find widget context keys for use in actions
59
export { CONTEXT_BROWSER_FIND_WIDGET_FOCUSED, CONTEXT_BROWSER_FIND_WIDGET_VISIBLE };
60
61
/**
62
* Get the original implementation of HTMLElement focus (without window auto-focusing)
63
* before it gets overridden by the workbench.
64
*/
65
const originalHtmlElementFocus = HTMLElement.prototype.focus;
66
67
class BrowserNavigationBar extends Disposable {
68
private readonly _urlInput: HTMLInputElement;
69
70
constructor(
71
editor: BrowserEditor,
72
container: HTMLElement,
73
instantiationService: IInstantiationService,
74
scopedContextKeyService: IContextKeyService
75
) {
76
super();
77
78
// Create hover delegate for toolbar buttons
79
const hoverDelegate = this._register(
80
instantiationService.createInstance(
81
WorkbenchHoverDelegate,
82
'element',
83
undefined,
84
{ position: { hoverPosition: HoverPosition.ABOVE } }
85
)
86
);
87
88
// Create navigation toolbar (left side) with scoped context
89
const navContainer = $('.browser-nav-toolbar');
90
const scopedInstantiationService = instantiationService.createChild(new ServiceCollection(
91
[IContextKeyService, scopedContextKeyService]
92
));
93
const navToolbar = this._register(scopedInstantiationService.createInstance(
94
MenuWorkbenchToolBar,
95
navContainer,
96
MenuId.BrowserNavigationToolbar,
97
{
98
hoverDelegate,
99
highlightToggledItems: true,
100
// Render all actions inline regardless of group
101
toolbarOptions: { primaryGroup: () => true, useSeparatorsInPrimaryActions: true },
102
menuOptions: { shouldForwardArgs: true }
103
}
104
));
105
106
// URL input
107
this._urlInput = $<HTMLInputElement>('input.browser-url-input');
108
this._urlInput.type = 'text';
109
this._urlInput.placeholder = localize('browser.urlPlaceholder', "Enter a URL");
110
111
// Create actions toolbar (right side) with scoped context
112
const actionsContainer = $('.browser-actions-toolbar');
113
const actionsToolbar = this._register(scopedInstantiationService.createInstance(
114
MenuWorkbenchToolBar,
115
actionsContainer,
116
MenuId.BrowserActionsToolbar,
117
{
118
hoverDelegate,
119
highlightToggledItems: true,
120
toolbarOptions: { primaryGroup: (group) => group.startsWith('actions'), useSeparatorsInPrimaryActions: true },
121
menuOptions: { shouldForwardArgs: true }
122
}
123
));
124
125
navToolbar.context = editor;
126
actionsToolbar.context = editor;
127
128
// Assemble layout: nav | url | actions
129
container.appendChild(navContainer);
130
container.appendChild(this._urlInput);
131
container.appendChild(actionsContainer);
132
133
// Setup URL input handler
134
this._register(addDisposableListener(this._urlInput, EventType.KEY_DOWN, (e: KeyboardEvent) => {
135
if (e.key === 'Enter') {
136
const url = this._urlInput.value.trim();
137
if (url) {
138
editor.navigateToUrl(url);
139
}
140
}
141
}));
142
143
// Select all URL bar text when the URL bar receives focus (like in regular browsers)
144
this._register(addDisposableListener(this._urlInput, EventType.FOCUS, () => {
145
this._urlInput.select();
146
}));
147
}
148
149
/**
150
* Update the navigation bar state from a navigation event
151
*/
152
updateFromNavigationEvent(event: IBrowserViewNavigationEvent): void {
153
// URL input is updated, action enablement is handled by context keys
154
this._urlInput.value = event.url;
155
}
156
157
/**
158
* Focus the URL input and select all text
159
*/
160
focusUrlInput(): void {
161
this._urlInput.select();
162
this._urlInput.focus();
163
}
164
165
clear(): void {
166
this._urlInput.value = '';
167
}
168
}
169
170
export class BrowserEditor extends EditorPane {
171
static readonly ID = 'workbench.editor.browser';
172
173
private _overlayVisible = false;
174
private _editorVisible = false;
175
private _currentKeyDownEvent: IBrowserViewKeyDownEvent | undefined;
176
177
private _navigationBar!: BrowserNavigationBar;
178
private _browserContainer!: HTMLElement;
179
private _placeholderScreenshot!: HTMLElement;
180
private _overlayPauseContainer!: HTMLElement;
181
private _overlayPauseHeading!: HTMLElement;
182
private _overlayPauseDetail!: HTMLElement;
183
private _errorContainer!: HTMLElement;
184
private _welcomeContainer!: HTMLElement;
185
private _findWidgetContainer!: HTMLElement;
186
private _findWidget!: Lazy<BrowserFindWidget>;
187
private _canGoBackContext!: IContextKey<boolean>;
188
private _canGoForwardContext!: IContextKey<boolean>;
189
private _storageScopeContext!: IContextKey<string>;
190
private _hasUrlContext!: IContextKey<boolean>;
191
private _devToolsOpenContext!: IContextKey<boolean>;
192
private _elementSelectionActiveContext!: IContextKey<boolean>;
193
194
private _model: IBrowserViewModel | undefined;
195
private readonly _inputDisposables = this._register(new DisposableStore());
196
private overlayManager: BrowserOverlayManager | undefined;
197
private _elementSelectionCts: CancellationTokenSource | undefined;
198
private _screenshotTimeout: ReturnType<typeof setTimeout> | undefined;
199
200
constructor(
201
group: IEditorGroup,
202
@ITelemetryService telemetryService: ITelemetryService,
203
@IThemeService themeService: IThemeService,
204
@IStorageService storageService: IStorageService,
205
@IKeybindingService private readonly keybindingService: IKeybindingService,
206
@ILogService private readonly logService: ILogService,
207
@IInstantiationService private readonly instantiationService: IInstantiationService,
208
@IContextKeyService private readonly contextKeyService: IContextKeyService,
209
@IEditorService private readonly editorService: IEditorService,
210
@IBrowserElementsService private readonly browserElementsService: IBrowserElementsService,
211
@IChatWidgetService private readonly chatWidgetService: IChatWidgetService,
212
@IConfigurationService private readonly configurationService: IConfigurationService
213
) {
214
super(BrowserEditor.ID, group, telemetryService, themeService, storageService);
215
}
216
217
protected override createEditor(parent: HTMLElement): void {
218
// Create scoped context key service for this editor instance
219
const contextKeyService = this._register(this.contextKeyService.createScoped(parent));
220
221
// Create window-specific overlay manager for this editor
222
this.overlayManager = this._register(new BrowserOverlayManager(this.window));
223
224
// Bind navigation capability context keys
225
this._canGoBackContext = CONTEXT_BROWSER_CAN_GO_BACK.bindTo(contextKeyService);
226
this._canGoForwardContext = CONTEXT_BROWSER_CAN_GO_FORWARD.bindTo(contextKeyService);
227
this._storageScopeContext = CONTEXT_BROWSER_STORAGE_SCOPE.bindTo(contextKeyService);
228
this._hasUrlContext = CONTEXT_BROWSER_HAS_URL.bindTo(contextKeyService);
229
this._devToolsOpenContext = CONTEXT_BROWSER_DEVTOOLS_OPEN.bindTo(contextKeyService);
230
this._elementSelectionActiveContext = CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE.bindTo(contextKeyService);
231
232
// Currently this is always true since it is scoped to the editor container
233
CONTEXT_BROWSER_FOCUSED.bindTo(contextKeyService);
234
235
// Create root container
236
const root = $('.browser-root');
237
parent.appendChild(root);
238
239
// Create toolbar with navigation buttons and URL input
240
const toolbar = $('.browser-toolbar');
241
242
// Create navigation bar widget with scoped context
243
this._navigationBar = this._register(new BrowserNavigationBar(this, toolbar, this.instantiationService, contextKeyService));
244
245
root.appendChild(toolbar);
246
247
// Create find widget container (between toolbar and browser container)
248
this._findWidgetContainer = $('.browser-find-widget-wrapper');
249
root.appendChild(this._findWidgetContainer);
250
251
// Create find widget (lazy initialization)
252
this._findWidget = new Lazy(() => {
253
const findWidget = this.instantiationService.createInstance(
254
BrowserFindWidget,
255
this._findWidgetContainer
256
);
257
if (this._model) {
258
findWidget.setModel(this._model);
259
}
260
return findWidget;
261
});
262
this._register(toDisposable(() => this._findWidget.rawValue?.dispose()));
263
264
// Create browser container (stub element for positioning)
265
this._browserContainer = $('.browser-container');
266
this._browserContainer.tabIndex = 0; // make focusable
267
root.appendChild(this._browserContainer);
268
269
// Create placeholder screenshot (background placeholder when WebContentsView is hidden)
270
this._placeholderScreenshot = $('.browser-placeholder-screenshot');
271
this._browserContainer.appendChild(this._placeholderScreenshot);
272
273
// Create overlay pause container (hidden by default via CSS)
274
this._overlayPauseContainer = $('.browser-overlay-paused');
275
const overlayPauseMessage = $('.browser-overlay-paused-message');
276
this._overlayPauseHeading = $('.browser-overlay-paused-heading');
277
this._overlayPauseDetail = $('.browser-overlay-paused-detail');
278
overlayPauseMessage.appendChild(this._overlayPauseHeading);
279
overlayPauseMessage.appendChild(this._overlayPauseDetail);
280
this._overlayPauseContainer.appendChild(overlayPauseMessage);
281
this._browserContainer.appendChild(this._overlayPauseContainer);
282
283
// Create error container (hidden by default)
284
this._errorContainer = $('.browser-error-container');
285
this._errorContainer.style.display = 'none';
286
this._browserContainer.appendChild(this._errorContainer);
287
288
// Create welcome container (shown when no URL is loaded)
289
this._welcomeContainer = this.createWelcomeContainer();
290
this._browserContainer.appendChild(this._welcomeContainer);
291
292
this._register(addDisposableListener(this._browserContainer, EventType.FOCUS, (event) => {
293
// When the browser container gets focus, make sure the browser view also gets focused.
294
// But only if focus was already in the workbench (and not e.g. clicking back into the workbench from the browser view).
295
if (event.relatedTarget && this._model && this.shouldShowView) {
296
void this._model.focus();
297
}
298
}));
299
300
// Register external focus checker so that cross-window focus logic knows when
301
// this browser view has focus (since it's outside the normal DOM tree).
302
// Include window info so that UI like dialogs appear in the correct window.
303
this._register(registerExternalFocusChecker(() => ({
304
hasFocus: this._model?.focused ?? false,
305
window: this._model?.focused ? this.window : undefined
306
})));
307
308
// Automatically call layoutBrowserContainer() when the browser container changes size.
309
// Be careful to use `ResizeObserver` from the target window to avoid cross-window issues.
310
const resizeObserver = new this.window.ResizeObserver(() => this.layoutBrowserContainer());
311
resizeObserver.observe(this._browserContainer);
312
this._register(toDisposable(() => resizeObserver.disconnect()));
313
}
314
315
override async setInput(input: BrowserEditorInput, options: IEditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise<void> {
316
await super.setInput(input, options, context, token);
317
if (token.isCancellationRequested) {
318
return;
319
}
320
321
this._inputDisposables.clear();
322
323
// Resolve the browser view model from the input
324
this._model = await input.resolve();
325
if (token.isCancellationRequested || this.input !== input) {
326
return;
327
}
328
329
this._storageScopeContext.set(this._model.storageScope);
330
this._devToolsOpenContext.set(this._model.isDevToolsOpen);
331
332
// Update find widget with new model
333
this._findWidget.rawValue?.setModel(this._model);
334
335
// Clean up on input disposal
336
this._inputDisposables.add(input.onWillDispose(() => {
337
this._model = undefined;
338
}));
339
340
// Initialize UI state and context keys from model
341
this.updateNavigationState({
342
url: this._model.url,
343
canGoBack: this._model.canGoBack,
344
canGoForward: this._model.canGoForward
345
});
346
this.setBackgroundImage(this._model.screenshot);
347
348
if (context.newInGroup) {
349
if (this._model.url) {
350
this._browserContainer.focus();
351
} else {
352
this.focusUrlInput();
353
}
354
}
355
356
// Start / stop screenshots when the model visibility changes
357
this._inputDisposables.add(this._model.onDidChangeVisibility(() => this.doScreenshot()));
358
359
// Listen to model events for UI updates
360
this._inputDisposables.add(this._model.onDidKeyCommand(keyEvent => {
361
// Handle like webview does - convert to webview KeyEvent format
362
this.handleKeyEventFromBrowserView(keyEvent);
363
}));
364
365
this._inputDisposables.add(this._model.onDidNavigate((navEvent: IBrowserViewNavigationEvent) => {
366
this.group.pinEditor(this.input); // pin editor on navigation
367
368
// Update navigation bar and context keys from model
369
this.updateNavigationState(navEvent);
370
}));
371
372
this._inputDisposables.add(this._model.onDidChangeLoadingState(() => {
373
this.updateErrorDisplay();
374
}));
375
376
this._inputDisposables.add(this._model.onDidChangeFocus(({ focused }) => {
377
// When the view gets focused, make sure the editor reports that it has focus,
378
// but focus is removed from the workbench.
379
if (focused) {
380
this._onDidFocus?.fire();
381
this.ensureBrowserFocus();
382
}
383
}));
384
385
this._inputDisposables.add(this._model.onDidChangeDevToolsState(e => {
386
this._devToolsOpenContext.set(e.isDevToolsOpen);
387
}));
388
389
this._inputDisposables.add(this._model.onDidRequestNewPage(({ resource, location, position }) => {
390
logBrowserOpen(this.telemetryService, (() => {
391
switch (location) {
392
case BrowserNewPageLocation.Background: return 'browserLinkBackground';
393
case BrowserNewPageLocation.Foreground: return 'browserLinkForeground';
394
case BrowserNewPageLocation.NewWindow: return 'browserLinkNewWindow';
395
}
396
})());
397
398
const targetGroup = location === BrowserNewPageLocation.NewWindow ? AUX_WINDOW_GROUP : this.group;
399
this.editorService.openEditor({
400
resource: URI.from(resource),
401
options: {
402
pinned: true,
403
inactive: location === BrowserNewPageLocation.Background,
404
auxiliary: {
405
bounds: position,
406
compact: true
407
}
408
}
409
}, targetGroup);
410
}));
411
412
this._inputDisposables.add(this.overlayManager!.onDidChangeOverlayState(() => {
413
this.checkOverlays();
414
}));
415
416
// Listen for zoom level changes and update browser view zoom factor
417
this._inputDisposables.add(onDidChangeZoomLevel(targetWindowId => {
418
if (targetWindowId === this.window.vscodeWindowId) {
419
this.layoutBrowserContainer();
420
}
421
}));
422
423
this.updateErrorDisplay();
424
this.layoutBrowserContainer();
425
this.updateVisibility();
426
this.doScreenshot();
427
}
428
429
protected override setEditorVisible(visible: boolean): void {
430
this._editorVisible = visible;
431
this.updateVisibility();
432
}
433
434
/**
435
* Make the browser container the active element without moving focus from the browser view.
436
*/
437
private ensureBrowserFocus(): void {
438
originalHtmlElementFocus.call(this._browserContainer);
439
}
440
441
private updateVisibility(): void {
442
const hasUrl = !!this._model?.url;
443
const hasError = !!this._model?.error;
444
const isViewingPage = !hasError && hasUrl;
445
const isPaused = isViewingPage && this._editorVisible && this._overlayVisible;
446
447
// Welcome container: shown when no URL is loaded
448
this._welcomeContainer.style.display = hasUrl ? 'none' : '';
449
450
// Error container: shown when there's a load error
451
this._errorContainer.style.display = hasError ? '' : 'none';
452
453
// Placeholder screenshot: shown when there is a page loaded (even when the view is not hidden, so hiding is smooth)
454
this._placeholderScreenshot.style.display = isViewingPage ? '' : 'none';
455
456
// Pause overlay: fades in when an overlay is detected
457
this._overlayPauseContainer.classList.toggle('visible', isPaused);
458
459
if (this._model) {
460
const show = this.shouldShowView;
461
if (show === this._model.visible) {
462
return;
463
}
464
465
if (show) {
466
this._model.setVisible(true);
467
if (
468
this._browserContainer.ownerDocument.hasFocus() &&
469
this._browserContainer.ownerDocument.activeElement === this._browserContainer
470
) {
471
// If the editor is focused, ensure the browser view also gets focus
472
void this._model.focus();
473
}
474
} else {
475
this.doScreenshot();
476
477
// Hide the browser view just before the next render.
478
// This attempts to give the screenshot some time to be captured and displayed.
479
// If we hide immediately it is more likely to flicker while the old screenshot is still visible.
480
this.window.requestAnimationFrame(() => this._model?.setVisible(false));
481
}
482
}
483
}
484
485
private get shouldShowView(): boolean {
486
return this._editorVisible && !this._overlayVisible && !this._model?.error && !!this._model?.url;
487
}
488
489
private checkOverlays(): void {
490
if (!this.overlayManager) {
491
return;
492
}
493
const overlappingOverlays = this.overlayManager.getOverlappingOverlays(this._browserContainer);
494
const hasOverlappingOverlay = overlappingOverlays.length > 0;
495
this.updateOverlayPauseMessage(overlappingOverlays);
496
if (hasOverlappingOverlay !== this._overlayVisible) {
497
this._overlayVisible = hasOverlappingOverlay;
498
this.updateVisibility();
499
}
500
}
501
502
private updateOverlayPauseMessage(overlappingOverlays: readonly IBrowserOverlayInfo[]): void {
503
// Only show the pause message for notification overlays
504
const hasNotificationOverlay = overlappingOverlays.some(overlay => overlay.type === BrowserOverlayType.Notification);
505
this._overlayPauseContainer.classList.toggle('show-message', hasNotificationOverlay);
506
507
if (hasNotificationOverlay) {
508
this._overlayPauseHeading.textContent = localize('browser.overlayPauseHeading.notification', "Paused due to Notification");
509
this._overlayPauseDetail.textContent = localize('browser.overlayPauseDetail.notification', "Dismiss the notification to continue using the browser.");
510
} else {
511
this._overlayPauseHeading.textContent = '';
512
this._overlayPauseDetail.textContent = '';
513
}
514
}
515
516
private updateErrorDisplay(): void {
517
if (!this._model) {
518
return;
519
}
520
521
const error: IBrowserViewLoadError | undefined = this._model.error;
522
if (error) {
523
// Update error content
524
525
while (this._errorContainer.firstChild) {
526
this._errorContainer.removeChild(this._errorContainer.firstChild);
527
}
528
529
const errorContent = $('.browser-error-content');
530
const errorTitle = $('.browser-error-title');
531
errorTitle.textContent = localize('browser.loadErrorLabel', "Failed to Load Page");
532
533
const errorMessage = $('.browser-error-detail');
534
const errorText = $('span');
535
errorText.textContent = `${error.errorDescription} (${error.errorCode})`;
536
errorMessage.appendChild(errorText);
537
538
const errorUrl = $('.browser-error-detail');
539
const urlLabel = $('strong');
540
urlLabel.textContent = localize('browser.errorUrlLabel', "URL:");
541
const urlValue = $('code');
542
urlValue.textContent = error.url;
543
errorUrl.appendChild(urlLabel);
544
errorUrl.appendChild(document.createTextNode(' '));
545
errorUrl.appendChild(urlValue);
546
547
errorContent.appendChild(errorTitle);
548
errorContent.appendChild(errorMessage);
549
errorContent.appendChild(errorUrl);
550
this._errorContainer.appendChild(errorContent);
551
552
this.setBackgroundImage(undefined);
553
} else {
554
this.setBackgroundImage(this._model.screenshot);
555
}
556
557
this.updateVisibility();
558
}
559
560
getUrl(): string | undefined {
561
return this._model?.url;
562
}
563
564
async navigateToUrl(url: string): Promise<void> {
565
if (this._model) {
566
this.group.pinEditor(this.input); // pin editor on navigation
567
568
// Special case localhost URLs (e.g., "localhost:3000") to add http://
569
if (/^localhost(:|\/|$)/i.test(url)) {
570
url = 'http://' + url;
571
} else if (!URL.parse(url)?.protocol) {
572
// If no scheme provided, default to http (sites will generally upgrade to https)
573
url = 'http://' + url;
574
}
575
576
this.ensureBrowserFocus();
577
await this._model.loadURL(url);
578
}
579
}
580
581
focusUrlInput(): void {
582
this._navigationBar.focusUrlInput();
583
}
584
585
async goBack(): Promise<void> {
586
return this._model?.goBack();
587
}
588
589
async goForward(): Promise<void> {
590
return this._model?.goForward();
591
}
592
593
async reload(): Promise<void> {
594
return this._model?.reload();
595
}
596
597
async toggleDevTools(): Promise<void> {
598
return this._model?.toggleDevTools();
599
}
600
601
async clearStorage(): Promise<void> {
602
return this._model?.clearStorage();
603
}
604
605
/**
606
* Show the find widget, optionally pre-populated with selected text from the browser view
607
*/
608
async showFind(): Promise<void> {
609
// Get selected text from the browser view to pre-populate the search box.
610
const selectedText = await this._model?.getSelectedText();
611
612
// Only use the selected text if it doesn't contain newlines (single line selection)
613
const textToReveal = selectedText && !/[\r\n]/.test(selectedText) ? selectedText : undefined;
614
this._findWidget.value.reveal(textToReveal);
615
this._findWidget.value.layout(this._findWidgetContainer.clientWidth);
616
}
617
618
/**
619
* Hide the find widget
620
*/
621
hideFind(): void {
622
this._findWidget.rawValue?.hide();
623
}
624
625
/**
626
* Find the next match
627
*/
628
findNext(): void {
629
this._findWidget.rawValue?.find(false);
630
}
631
632
/**
633
* Find the previous match
634
*/
635
findPrevious(): void {
636
this._findWidget.rawValue?.find(true);
637
}
638
639
/**
640
* Start element selection in the browser view, wait for a user selection, and add it to chat.
641
*/
642
async addElementToChat(): Promise<void> {
643
// If selection is already active, cancel it
644
if (this._elementSelectionCts) {
645
this._elementSelectionCts.dispose(true);
646
this._elementSelectionCts = undefined;
647
this._elementSelectionActiveContext.set(false);
648
return;
649
}
650
651
// Start new selection
652
const cts = new CancellationTokenSource();
653
this._elementSelectionCts = cts;
654
this._elementSelectionActiveContext.set(true);
655
656
type IntegratedBrowserAddElementToChatStartEvent = {};
657
658
type IntegratedBrowserAddElementToChatStartClassification = {
659
owner: 'jruales';
660
comment: 'The user initiated an Add Element to Chat action in Integrated Browser.';
661
};
662
663
this.telemetryService.publicLog2<IntegratedBrowserAddElementToChatStartEvent, IntegratedBrowserAddElementToChatStartClassification>('integratedBrowser.addElementToChat.start', {});
664
665
try {
666
// Get the resource URI for this editor
667
const resourceUri = this.input?.resource;
668
if (!resourceUri) {
669
throw new Error('No resource URI found');
670
}
671
672
// Make the browser the focused view
673
this.ensureBrowserFocus();
674
675
// Create a locator - for integrated browser, use the URI scheme to identify
676
// Browser view URIs have a special scheme we can match against
677
const locator: IBrowserTargetLocator = { browserViewId: BrowserViewUri.getId(this.input.resource) };
678
679
// Start debug session for integrated browser
680
await this.browserElementsService.startDebugSession(cts.token, locator);
681
682
// Get the browser container bounds
683
const { width, height } = this._browserContainer.getBoundingClientRect();
684
685
// Get element data from user selection
686
const elementData = await this.browserElementsService.getElementData({ x: 0, y: 0, width, height }, cts.token, locator);
687
if (!elementData) {
688
throw new Error('Element data not found');
689
}
690
691
const bounds = elementData.bounds;
692
const toAttach: IChatRequestVariableEntry[] = [];
693
694
// Prepare HTML/CSS context
695
const displayName = getDisplayNameFromOuterHTML(elementData.outerHTML);
696
const attachCss = this.configurationService.getValue<boolean>('chat.sendElementsToChat.attachCSS');
697
let value = (attachCss ? 'Attached HTML and CSS Context' : 'Attached HTML Context') + '\n\n' + elementData.outerHTML;
698
if (attachCss) {
699
value += '\n\n' + elementData.computedStyle;
700
}
701
702
toAttach.push({
703
id: 'element-' + Date.now(),
704
name: displayName,
705
fullName: displayName,
706
value: value,
707
kind: 'element',
708
icon: ThemeIcon.fromId(Codicon.layout.id),
709
});
710
711
// Attach screenshot if enabled
712
const attachImages = this.configurationService.getValue<boolean>('chat.sendElementsToChat.attachImages');
713
if (attachImages && this._model) {
714
const screenshotBuffer = await this._model.captureScreenshot({
715
quality: 90,
716
rect: bounds
717
});
718
719
toAttach.push({
720
id: 'element-screenshot-' + Date.now(),
721
name: 'Element Screenshot',
722
fullName: 'Element Screenshot',
723
kind: 'image',
724
value: screenshotBuffer.buffer
725
});
726
}
727
728
// Attach to chat widget
729
const widget = await this.chatWidgetService.revealWidget() ?? this.chatWidgetService.lastFocusedWidget;
730
widget?.attachmentModel?.addContext(...toAttach);
731
732
type IntegratedBrowserAddElementToChatAddedEvent = {
733
attachCss: boolean;
734
attachImages: boolean;
735
};
736
737
type IntegratedBrowserAddElementToChatAddedClassification = {
738
attachCss: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether chat.sendElementsToChat.attachCSS was enabled.' };
739
attachImages: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether chat.sendElementsToChat.attachImages was enabled.' };
740
owner: 'jruales';
741
comment: 'An element was successfully added to chat from Integrated Browser.';
742
};
743
744
this.telemetryService.publicLog2<IntegratedBrowserAddElementToChatAddedEvent, IntegratedBrowserAddElementToChatAddedClassification>('integratedBrowser.addElementToChat.added', {
745
attachCss,
746
attachImages
747
});
748
749
} catch (error) {
750
if (!cts.token.isCancellationRequested) {
751
this.logService.error('BrowserEditor.addElementToChat: Failed to select element', error);
752
}
753
} finally {
754
cts.dispose();
755
if (this._elementSelectionCts === cts) {
756
this._elementSelectionCts = undefined;
757
this._elementSelectionActiveContext.set(false);
758
}
759
}
760
}
761
762
/**
763
* Update navigation state and context keys
764
*/
765
private updateNavigationState(event: IBrowserViewNavigationEvent): void {
766
// Update navigation bar UI
767
this._navigationBar.updateFromNavigationEvent(event);
768
769
// Update context keys for command enablement
770
this._canGoBackContext.set(event.canGoBack);
771
this._canGoForwardContext.set(event.canGoForward);
772
this._hasUrlContext.set(!!event.url);
773
774
// Update visibility (welcome screen, error, browser view)
775
this.updateVisibility();
776
}
777
778
/**
779
* Create the welcome container shown when no URL is loaded
780
*/
781
private createWelcomeContainer(): HTMLElement {
782
const container = $('.browser-welcome-container');
783
const content = $('.browser-welcome-content');
784
785
const iconContainer = $('.browser-welcome-icon');
786
iconContainer.appendChild(renderIcon(Codicon.globe));
787
content.appendChild(iconContainer);
788
789
const title = $('.browser-welcome-title');
790
title.textContent = localize('browser.welcomeTitle', "Browser");
791
content.appendChild(title);
792
793
const subtitle = $('.browser-welcome-subtitle');
794
const chatEnabled = this.contextKeyService.getContextKeyValue<boolean>(ChatContextKeys.enabled.key);
795
subtitle.textContent = chatEnabled
796
? localize('browser.welcomeSubtitleChat', "Use Add Element to Chat to reference UI elements in chat prompts.")
797
: localize('browser.welcomeSubtitle', "Enter a URL above to get started.");
798
content.appendChild(subtitle);
799
800
container.appendChild(content);
801
return container;
802
}
803
804
private setBackgroundImage(buffer: VSBuffer | undefined): void {
805
if (buffer) {
806
const dataUrl = `data:image/jpeg;base64,${encodeBase64(buffer)}`;
807
this._placeholderScreenshot.style.backgroundImage = `url('${dataUrl}')`;
808
} else {
809
this._placeholderScreenshot.style.backgroundImage = '';
810
}
811
}
812
813
private async doScreenshot(): Promise<void> {
814
if (!this._model) {
815
return;
816
}
817
818
// Cancel any existing timeout
819
this.cancelScheduledScreenshot();
820
821
// Only take screenshots if the model is visible
822
if (!this._model.visible) {
823
return;
824
}
825
826
try {
827
// Capture screenshot and set as background image
828
const screenshot = await this._model.captureScreenshot({ quality: 80 });
829
this.setBackgroundImage(screenshot);
830
} catch (error) {
831
this.logService.error('Failed to capture browser view screenshot', error);
832
}
833
834
// Schedule next screenshot in 1 second
835
this._screenshotTimeout = setTimeout(() => this.doScreenshot(), 1000);
836
}
837
838
private cancelScheduledScreenshot(): void {
839
if (this._screenshotTimeout) {
840
clearTimeout(this._screenshotTimeout);
841
this._screenshotTimeout = undefined;
842
}
843
}
844
845
forwardCurrentEvent(): boolean {
846
if (this._currentKeyDownEvent && this._model) {
847
void this._model.dispatchKeyEvent(this._currentKeyDownEvent);
848
return true;
849
}
850
return false;
851
}
852
853
private async handleKeyEventFromBrowserView(keyEvent: IBrowserViewKeyDownEvent): Promise<void> {
854
this._currentKeyDownEvent = keyEvent;
855
856
try {
857
const syntheticEvent = new KeyboardEvent('keydown', keyEvent);
858
const standardEvent = new StandardKeyboardEvent(syntheticEvent);
859
860
const handled = this.keybindingService.dispatchEvent(standardEvent, this._browserContainer);
861
if (!handled) {
862
this.forwardCurrentEvent();
863
}
864
} catch (error) {
865
this.logService.error('BrowserEditor.handleKeyEventFromBrowserView: Error dispatching key event', error);
866
} finally {
867
this._currentKeyDownEvent = undefined;
868
}
869
}
870
871
override layout(dimension: Dimension, _position?: IDomPosition): void {
872
// Layout find widget if it exists
873
this._findWidget.rawValue?.layout(dimension.width);
874
}
875
876
/**
877
* This should be called whenever .browser-container changes in size, or when
878
* there could be any elements, such as the command palette, overlapping with it.
879
*
880
* Note that we don't call layoutBrowserContainer() from layout() but instead rely on using a ResizeObserver and on
881
* making direct calls to it. This is because we have seen cases where the getBoundingClientRect() values of
882
* the .browser-container element are not correct during layout() calls, especially during "Move into New Window"
883
* and "Copy into New Window" operations into a different monitor.
884
*/
885
layoutBrowserContainer(): void {
886
if (this._model) {
887
this.checkOverlays();
888
889
const containerRect = this._browserContainer.getBoundingClientRect();
890
void this._model.layout({
891
windowId: this.group.windowId,
892
x: containerRect.left,
893
y: containerRect.top,
894
width: containerRect.width,
895
height: containerRect.height,
896
zoomFactor: getZoomFactor(this.window)
897
});
898
}
899
}
900
901
override clearInput(): void {
902
this._inputDisposables.clear();
903
904
// Cancel any active element selection
905
if (this._elementSelectionCts) {
906
this._elementSelectionCts.dispose(true);
907
this._elementSelectionCts = undefined;
908
}
909
910
// Cancel any scheduled screenshots
911
this.cancelScheduledScreenshot();
912
913
// Clear find widget model
914
this._findWidget.rawValue?.setModel(undefined);
915
this._findWidget.rawValue?.hide();
916
917
void this._model?.setVisible(false);
918
this._model = undefined;
919
920
this._canGoBackContext.reset();
921
this._canGoForwardContext.reset();
922
this._hasUrlContext.reset();
923
this._storageScopeContext.reset();
924
this._devToolsOpenContext.reset();
925
this._elementSelectionActiveContext.reset();
926
927
this._navigationBar.clear();
928
this.setBackgroundImage(undefined);
929
930
super.clearInput();
931
}
932
}
933
934