Path: blob/main/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts
4780 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 './media/browser.css';6import { localize } from '../../../../nls.js';7import { $, addDisposableListener, disposableWindowInterval, EventType, scheduleAtNextAnimationFrame } from '../../../../base/browser/dom.js';8import { CancellationToken } from '../../../../base/common/cancellation.js';9import { RawContextKey, IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';10import { MenuId } from '../../../../platform/actions/common/actions.js';11import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';12import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js';13import { IEditorService } from '../../../services/editor/common/editorService.js';14import { EditorPane } from '../../../browser/parts/editor/editorPane.js';15import { IEditorOpenContext } from '../../../common/editor.js';16import { BrowserEditorInput } from './browserEditorInput.js';17import { BrowserViewUri } from '../../../../platform/browserView/common/browserViewUri.js';18import { IBrowserViewModel } from '../../browserView/common/browserView.js';19import { IThemeService } from '../../../../platform/theme/common/themeService.js';20import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js';21import { IStorageService } from '../../../../platform/storage/common/storage.js';22import { IBrowserViewKeyDownEvent, IBrowserViewNavigationEvent, IBrowserViewLoadError } from '../../../../platform/browserView/common/browserView.js';23import { IEditorGroup } from '../../../services/editor/common/editorGroupsService.js';24import { IEditorOptions } from '../../../../platform/editor/common/editor.js';25import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js';26import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js';27import { BrowserOverlayManager } from './overlayManager.js';28import { getZoomFactor, onDidChangeZoomLevel } from '../../../../base/browser/browser.js';29import { ILogService } from '../../../../platform/log/common/log.js';30import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js';31import { WorkbenchHoverDelegate } from '../../../../platform/hover/browser/hover.js';32import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js';33import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js';34import { encodeBase64, VSBuffer } from '../../../../base/common/buffer.js';3536export const CONTEXT_BROWSER_CAN_GO_BACK = new RawContextKey<boolean>('browserCanGoBack', false, localize('browser.canGoBack', "Whether the browser can go back"));37export const CONTEXT_BROWSER_CAN_GO_FORWARD = new RawContextKey<boolean>('browserCanGoForward', false, localize('browser.canGoForward', "Whether the browser can go forward"));38export const CONTEXT_BROWSER_FOCUSED = new RawContextKey<boolean>('browserFocused', true, localize('browser.editorFocused', "Whether the browser editor is focused"));39export const CONTEXT_BROWSER_STORAGE_SCOPE = new RawContextKey<string>('browserStorageScope', '', localize('browser.storageScope', "The storage scope of the current browser view"));40export const CONTEXT_BROWSER_DEVTOOLS_OPEN = new RawContextKey<boolean>('browserDevToolsOpen', false, localize('browser.devToolsOpen', "Whether developer tools are open for the current browser view"));4142class BrowserNavigationBar extends Disposable {43private readonly _urlInput: HTMLInputElement;4445constructor(46editor: BrowserEditor,47container: HTMLElement,48instantiationService: IInstantiationService,49scopedContextKeyService: IContextKeyService50) {51super();5253// Create hover delegate for toolbar buttons54const hoverDelegate = this._register(55instantiationService.createInstance(56WorkbenchHoverDelegate,57'element',58undefined,59{ position: { hoverPosition: HoverPosition.ABOVE } }60)61);6263// Create navigation toolbar (left side) with scoped context64const navContainer = $('.browser-nav-toolbar');65const scopedInstantiationService = instantiationService.createChild(new ServiceCollection(66[IContextKeyService, scopedContextKeyService]67));68const navToolbar = this._register(scopedInstantiationService.createInstance(69MenuWorkbenchToolBar,70navContainer,71MenuId.BrowserNavigationToolbar,72{73hoverDelegate,74highlightToggledItems: true,75// Render all actions inline regardless of group76toolbarOptions: { primaryGroup: () => true, useSeparatorsInPrimaryActions: true },77menuOptions: { shouldForwardArgs: true }78}79));8081// URL input82this._urlInput = $<HTMLInputElement>('input.browser-url-input');83this._urlInput.type = 'text';84this._urlInput.placeholder = localize('browser.urlPlaceholder', "Enter URL...");8586// Create actions toolbar (right side) with scoped context87const actionsContainer = $('.browser-actions-toolbar');88const actionsToolbar = this._register(scopedInstantiationService.createInstance(89MenuWorkbenchToolBar,90actionsContainer,91MenuId.BrowserActionsToolbar,92{93hoverDelegate,94highlightToggledItems: true,95toolbarOptions: { primaryGroup: 'actions' },96menuOptions: { shouldForwardArgs: true }97}98));99100navToolbar.context = editor;101actionsToolbar.context = editor;102103// Assemble layout: nav | url | actions104container.appendChild(navContainer);105container.appendChild(this._urlInput);106container.appendChild(actionsContainer);107108// Setup URL input handler109this._register(addDisposableListener(this._urlInput, EventType.KEY_DOWN, (e: KeyboardEvent) => {110if (e.key === 'Enter') {111const url = this._urlInput.value.trim();112if (url) {113editor.navigateToUrl(url);114}115}116}));117}118119/**120* Update the navigation bar state from a navigation event121*/122updateFromNavigationEvent(event: IBrowserViewNavigationEvent): void {123// URL input is updated, action enablement is handled by context keys124this._urlInput.value = event.url;125}126127/**128* Focus the URL input and select all text129*/130focusUrlInput(): void {131this._urlInput.select();132this._urlInput.focus();133}134135clear(): void {136this._urlInput.value = '';137}138}139140export class BrowserEditor extends EditorPane {141static readonly ID = 'workbench.editor.browser';142143private _overlayVisible = false;144private _editorVisible = false;145private _currentKeyDownEvent: IBrowserViewKeyDownEvent | undefined;146147private _navigationBar!: BrowserNavigationBar;148private _browserContainer!: HTMLElement;149private _errorContainer!: HTMLElement;150private _canGoBackContext!: IContextKey<boolean>;151private _canGoForwardContext!: IContextKey<boolean>;152private _storageScopeContext!: IContextKey<string>;153private _devToolsOpenContext!: IContextKey<boolean>;154155private _model: IBrowserViewModel | undefined;156private readonly _inputDisposables = this._register(new DisposableStore());157private overlayManager: BrowserOverlayManager | undefined;158159constructor(160group: IEditorGroup,161@ITelemetryService telemetryService: ITelemetryService,162@IThemeService themeService: IThemeService,163@IStorageService storageService: IStorageService,164@IKeybindingService private readonly keybindingService: IKeybindingService,165@ILogService private readonly logService: ILogService,166@IInstantiationService private readonly instantiationService: IInstantiationService,167@IContextKeyService private readonly contextKeyService: IContextKeyService,168@IEditorService private readonly editorService: IEditorService169) {170super(BrowserEditor.ID, group, telemetryService, themeService, storageService);171}172173protected override createEditor(parent: HTMLElement): void {174// Create scoped context key service for this editor instance175const contextKeyService = this._register(this.contextKeyService.createScoped(parent));176177// Create window-specific overlay manager for this editor178this.overlayManager = this._register(new BrowserOverlayManager(this.window));179180// Bind navigation capability context keys181this._canGoBackContext = CONTEXT_BROWSER_CAN_GO_BACK.bindTo(contextKeyService);182this._canGoForwardContext = CONTEXT_BROWSER_CAN_GO_FORWARD.bindTo(contextKeyService);183this._storageScopeContext = CONTEXT_BROWSER_STORAGE_SCOPE.bindTo(contextKeyService);184this._devToolsOpenContext = CONTEXT_BROWSER_DEVTOOLS_OPEN.bindTo(contextKeyService);185186// Currently this is always true since it is scoped to the editor container187CONTEXT_BROWSER_FOCUSED.bindTo(contextKeyService);188189// Create root container190const root = $('.browser-root');191parent.appendChild(root);192193// Create toolbar with navigation buttons and URL input194const toolbar = $('.browser-toolbar');195196// Create navigation bar widget with scoped context197this._navigationBar = this._register(new BrowserNavigationBar(this, toolbar, this.instantiationService, contextKeyService));198199root.appendChild(toolbar);200201// Create browser container (stub element for positioning)202this._browserContainer = $('.browser-container');203this._browserContainer.tabIndex = 0; // make focusable204root.appendChild(this._browserContainer);205206// Create error container (hidden by default)207this._errorContainer = $('.browser-error-container');208this._errorContainer.style.display = 'none';209this._browserContainer.appendChild(this._errorContainer);210211this._register(addDisposableListener(this._browserContainer, EventType.FOCUS, (event) => {212// When the browser container gets focus, make sure the browser view also gets focused.213// But only if focus was already in the workbench (and not e.g. clicking back into the workbench from the browser view).214if (event.relatedTarget && this._model && this.shouldShowView) {215void this._model.focus();216}217}));218219this._register(addDisposableListener(this._browserContainer, EventType.BLUR, () => {220// When focus goes to another part of the workbench, make sure the workbench view becomes focused.221const focused = this.window.document.activeElement;222if (focused && focused !== this._browserContainer) {223this.window.focus();224}225}));226}227228override async setInput(input: BrowserEditorInput, options: IEditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise<void> {229await super.setInput(input, options, context, token);230if (token.isCancellationRequested) {231return;232}233234this._inputDisposables.clear();235236// Resolve the browser view model from the input237this._model = await input.resolve();238if (token.isCancellationRequested || this.input !== input) {239return;240}241242this._storageScopeContext.set(this._model.storageScope);243this._devToolsOpenContext.set(this._model.isDevToolsOpen);244245// Clean up on input disposal246this._inputDisposables.add(input.onWillDispose(() => {247this._model = undefined;248}));249250// Initialize UI state and context keys from model251this.updateNavigationState({252url: this._model.url,253canGoBack: this._model.canGoBack,254canGoForward: this._model.canGoForward255});256this.setBackgroundImage(this._model.screenshot);257258if (context.newInGroup) {259this._navigationBar.focusUrlInput();260}261262// Listen to model events for UI updates263this._inputDisposables.add(this._model.onDidKeyCommand(keyEvent => {264// Handle like webview does - convert to webview KeyEvent format265this.handleKeyEventFromBrowserView(keyEvent);266}));267268this._inputDisposables.add(this._model.onDidNavigate((navEvent: IBrowserViewNavigationEvent) => {269this.group.pinEditor(this.input); // pin editor on navigation270271// Update navigation bar and context keys from model272this.updateNavigationState(navEvent);273}));274275this._inputDisposables.add(this._model.onDidChangeLoadingState(() => {276this.updateErrorDisplay();277}));278279this._inputDisposables.add(this._model.onDidChangeFocus(({ focused }) => {280// When the view gets focused, make sure the container also has focus.281if (focused) {282this._browserContainer.focus();283}284}));285286this._inputDisposables.add(this._model.onDidChangeDevToolsState(e => {287this._devToolsOpenContext.set(e.isDevToolsOpen);288}));289290this._inputDisposables.add(this._model.onDidRequestNewPage(({ url, name, background }) => {291type IntegratedBrowserNewPageRequestEvent = {292background: boolean;293};294295type IntegratedBrowserNewPageRequestClassification = {296background: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether page was requested to open in background' };297owner: 'kycutler';298comment: 'Tracks new page requests from integrated browser';299};300301this.telemetryService.publicLog2<IntegratedBrowserNewPageRequestEvent, IntegratedBrowserNewPageRequestClassification>(302'integratedBrowser.newPageRequest',303{304background305}306);307308// Open a new browser tab for the requested URL309const browserUri = BrowserViewUri.forUrl(url, name ? `${input.id}-${name}` : undefined);310this.editorService.openEditor({311resource: browserUri,312options: {313pinned: true,314inactive: background315}316}, this.group);317}));318319this._inputDisposables.add(this.overlayManager!.onDidChangeOverlayState(() => {320this.checkOverlays();321}));322323// Listen for zoom level changes and update browser view zoom factor324this._inputDisposables.add(onDidChangeZoomLevel(targetWindowId => {325if (targetWindowId === this.window.vscodeWindowId) {326this.layout();327}328}));329// Capture screenshot periodically (once per second) to keep background updated330this._inputDisposables.add(disposableWindowInterval(331this.window,332() => this.capturePlaceholderSnapshot(),3331000334));335336this.updateErrorDisplay();337this.layout();338await this._model.setVisible(this.shouldShowView);339340// Sometimes the element has not been inserted into the DOM yet. Ensure layout after next animation frame.341scheduleAtNextAnimationFrame(this.window, () => this.layout());342}343344protected override setEditorVisible(visible: boolean): void {345this._editorVisible = visible;346this.updateVisibility();347}348349private updateVisibility(): void {350if (this._model) {351// Blur the background image if the view is hidden due to an overlay.352this._browserContainer.classList.toggle('blur', this._editorVisible && this._overlayVisible && !this._model?.error);353void this._model.setVisible(this.shouldShowView);354}355}356357private get shouldShowView(): boolean {358return this._editorVisible && !this._overlayVisible && !this._model?.error;359}360361private checkOverlays(): void {362if (!this.overlayManager) {363return;364}365const hasOverlappingOverlay = this.overlayManager.isOverlappingWithOverlays(this._browserContainer);366if (hasOverlappingOverlay !== this._overlayVisible) {367this._overlayVisible = hasOverlappingOverlay;368this.updateVisibility();369}370}371372private updateErrorDisplay(): void {373if (!this._model) {374return;375}376377const error: IBrowserViewLoadError | undefined = this._model.error;378if (error) {379// Show error display380this._errorContainer.style.display = 'flex';381382while (this._errorContainer.firstChild) {383this._errorContainer.removeChild(this._errorContainer.firstChild);384}385386const errorContent = $('.browser-error-content');387const errorTitle = $('.browser-error-title');388errorTitle.textContent = localize('browser.loadErrorLabel', "Failed to Load Page");389390const errorMessage = $('.browser-error-detail');391const errorText = $('span');392errorText.textContent = `${error.errorDescription} (${error.errorCode})`;393errorMessage.appendChild(errorText);394395const errorUrl = $('.browser-error-detail');396const urlLabel = $('strong');397urlLabel.textContent = localize('browser.errorUrlLabel', "URL:");398const urlValue = $('code');399urlValue.textContent = error.url;400errorUrl.appendChild(urlLabel);401errorUrl.appendChild(document.createTextNode(' '));402errorUrl.appendChild(urlValue);403404errorContent.appendChild(errorTitle);405errorContent.appendChild(errorMessage);406errorContent.appendChild(errorUrl);407this._errorContainer.appendChild(errorContent);408409this.setBackgroundImage(undefined);410} else {411// Hide error display412this._errorContainer.style.display = 'none';413this.setBackgroundImage(this._model.screenshot);414}415416this.updateVisibility();417}418419async navigateToUrl(url: string): Promise<void> {420if (this._model) {421this.group.pinEditor(this.input); // pin editor on navigation422423const scheme = URL.parse(url)?.protocol;424if (!scheme) {425// If no scheme provided, default to http (to support localhost etc -- sites will generally upgrade to https)426url = 'http://' + url;427}428429await this._model.loadURL(url);430}431}432433async goBack(): Promise<void> {434return this._model?.goBack();435}436437async goForward(): Promise<void> {438return this._model?.goForward();439}440441async reload(): Promise<void> {442return this._model?.reload();443}444445async toggleDevTools(): Promise<void> {446return this._model?.toggleDevTools();447}448449/**450* Update navigation state and context keys451*/452private updateNavigationState(event: IBrowserViewNavigationEvent): void {453// Update navigation bar UI454this._navigationBar.updateFromNavigationEvent(event);455456// Update context keys for command enablement457this._canGoBackContext.set(event.canGoBack);458this._canGoForwardContext.set(event.canGoForward);459}460461private setBackgroundImage(buffer: VSBuffer | undefined): void {462if (buffer) {463const dataUrl = `data:image/jpeg;base64,${encodeBase64(buffer)}`;464this._browserContainer.style.backgroundImage = `url('${dataUrl}')`;465} else {466this._browserContainer.style.backgroundImage = '';467}468}469470/**471* Capture a screenshot of the current browser view to use as placeholder background472*/473private async capturePlaceholderSnapshot(): Promise<void> {474if (this._model && !this._overlayVisible) {475try {476const buffer = await this._model.captureScreenshot({ quality: 80 });477this.setBackgroundImage(buffer);478} catch (error) {479this.logService.error('BrowserEditor.capturePlaceholderSnapshot: Failed to capture screenshot', error);480}481}482}483484forwardCurrentEvent(): boolean {485if (this._currentKeyDownEvent && this._model) {486void this._model.dispatchKeyEvent(this._currentKeyDownEvent);487return true;488}489return false;490}491492private async handleKeyEventFromBrowserView(keyEvent: IBrowserViewKeyDownEvent): Promise<void> {493this._currentKeyDownEvent = keyEvent;494495try {496const syntheticEvent = new KeyboardEvent('keydown', keyEvent);497const standardEvent = new StandardKeyboardEvent(syntheticEvent);498499const handled = this.keybindingService.dispatchEvent(standardEvent, this._browserContainer);500if (!handled) {501this.forwardCurrentEvent();502}503} catch (error) {504this.logService.error('BrowserEditor.handleKeyEventFromBrowserView: Error dispatching key event', error);505} finally {506this._currentKeyDownEvent = undefined;507}508}509510override layout(): void {511if (this._model) {512this.checkOverlays();513514const containerRect = this._browserContainer.getBoundingClientRect();515void this._model.layout({516windowId: this.group.windowId,517x: containerRect.left,518y: containerRect.top,519width: containerRect.width,520height: containerRect.height,521zoomFactor: getZoomFactor(this.window)522});523}524}525526override clearInput(): void {527this._inputDisposables.clear();528529void this._model?.setVisible(false);530this._model = undefined;531532this._canGoBackContext.reset();533this._canGoForwardContext.reset();534this._storageScopeContext.reset();535this._devToolsOpenContext.reset();536537this._navigationBar.clear();538this.setBackgroundImage(undefined);539540super.clearInput();541}542}543544545