diff --git a/src/vs/base/common/keyCodes.ts b/src/vs/base/common/keyCodes.ts index 8824bd526e30f..14f5f4b5fcccc 100644 --- a/src/vs/base/common/keyCodes.ts +++ b/src/vs/base/common/keyCodes.ts @@ -456,6 +456,7 @@ const userSettingsUSMap = new KeyCodeStrMap(); const userSettingsGeneralMap = new KeyCodeStrMap(); export const EVENT_KEY_CODE_MAP: { [keyCode: number]: KeyCode } = new Array(230); export const NATIVE_WINDOWS_KEY_CODE_TO_KEY_CODE: { [nativeKeyCode: string]: KeyCode } = {}; +export const SCAN_CODE_STR_TO_EVENT_KEY_CODE: { [scanCodeStr: string]: number } = {}; const scanCodeIntToStr: string[] = []; const scanCodeStrToInt: { [code: string]: number } = Object.create(null); const scanCodeLowerCaseStrToInt: { [code: string]: number } = Object.create(null); @@ -755,6 +756,9 @@ for (let i = 0; i <= KeyCode.MAX_VALUE; i++) { if (eventKeyCode) { EVENT_KEY_CODE_MAP[eventKeyCode] = keyCode; } + if (scanCodeStr) { + SCAN_CODE_STR_TO_EVENT_KEY_CODE[scanCodeStr] = eventKeyCode; + } if (vkey) { NATIVE_WINDOWS_KEY_CODE_TO_KEY_CODE[vkey] = keyCode; } diff --git a/src/vs/base/common/network.ts b/src/vs/base/common/network.ts index e47b42672fba1..74cb106fd3cef 100644 --- a/src/vs/base/common/network.ts +++ b/src/vs/base/common/network.ts @@ -100,6 +100,11 @@ export namespace Schemas { */ export const vscodeWebview = 'vscode-webview'; + /** + * Scheme used for integrated browser tabs using WebContentsView. + */ + export const vscodeBrowser = 'vscode-browser'; + /** * Scheme used for extension pages */ diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index 43aba01458636..05ebd48ea6bf4 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -36,6 +36,8 @@ import { DialogMainService, IDialogMainService } from '../../platform/dialogs/el import { IEncryptionMainService } from '../../platform/encryption/common/encryptionService.js'; import { EncryptionMainService } from '../../platform/encryption/electron-main/encryptionMainService.js'; import { NativeBrowserElementsMainService, INativeBrowserElementsMainService } from '../../platform/browserElements/electron-main/nativeBrowserElementsMainService.js'; +import { ipcBrowserViewChannelName } from '../../platform/browserView/common/browserView.js'; +import { BrowserViewMainService, IBrowserViewMainService } from '../../platform/browserView/electron-main/browserViewMainService.js'; import { NativeParsedArgs } from '../../platform/environment/common/argv.js'; import { IEnvironmentMainService } from '../../platform/environment/electron-main/environmentMainService.js'; import { isLaunchedFromCli } from '../../platform/environment/node/argvHelper.js'; @@ -411,11 +413,15 @@ export class CodeApplication extends Disposable { this.auxiliaryWindowsMainService?.registerWindow(contents); } - // Block any in-page navigation + // Handle any in-page navigation contents.on('will-navigate', event => { + if (BrowserViewMainService.isBrowserViewWebContents(contents)) { + return; // Allow navigation in integrated browser views + } + this.logService.error('webContents#will-navigate: Prevented webcontent navigation'); - event.preventDefault(); + event.preventDefault(); // Prevent any in-page navigation }); // All Windows: only allow about:blank auxiliary windows to open @@ -1021,6 +1027,9 @@ export class CodeApplication extends Disposable { // Browser Elements services.set(INativeBrowserElementsMainService, new SyncDescriptor(NativeBrowserElementsMainService, undefined, false /* proxied to other processes */)); + // Browser View + services.set(IBrowserViewMainService, new SyncDescriptor(BrowserViewMainService, undefined, false /* proxied to other processes */)); + // Keyboard Layout services.set(IKeyboardLayoutMainService, new SyncDescriptor(KeyboardLayoutMainService)); @@ -1168,6 +1177,10 @@ export class CodeApplication extends Disposable { mainProcessElectronServer.registerChannel('browserElements', browserElementsChannel); sharedProcessClient.then(client => client.registerChannel('browserElements', browserElementsChannel)); + // Browser View + const browserViewChannel = ProxyChannel.fromService(accessor.get(IBrowserViewMainService), disposables); + mainProcessElectronServer.registerChannel(ipcBrowserViewChannelName, browserViewChannel); + // Signing const signChannel = ProxyChannel.fromService(accessor.get(ISignService), disposables); mainProcessElectronServer.registerChannel('sign', signChannel); diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index 09782cdf11673..b82fea314178e 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -283,6 +283,8 @@ export class MenuId { static readonly MultiDiffEditorFileToolbar = new MenuId('MultiDiffEditorFileToolbar'); static readonly DiffEditorHunkToolbar = new MenuId('DiffEditorHunkToolbar'); static readonly DiffEditorSelectionToolbar = new MenuId('DiffEditorSelectionToolbar'); + static readonly BrowserNavigationToolbar = new MenuId('BrowserNavigationToolbar'); + static readonly BrowserActionsToolbar = new MenuId('BrowserActionsToolbar'); static readonly AgentSessionsViewerFilterSubMenu = new MenuId('AgentSessionsViewerFilterSubMenu'); static readonly AgentSessionsContext = new MenuId('AgentSessionsContext'); static readonly AgentSessionsCreateSubMenu = new MenuId('AgentSessionsCreateSubMenu'); diff --git a/src/vs/platform/browserView/common/browserView.ts b/src/vs/platform/browserView/common/browserView.ts new file mode 100644 index 0000000000000..335147600e69e --- /dev/null +++ b/src/vs/platform/browserView/common/browserView.ts @@ -0,0 +1,216 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../base/common/event.js'; +import { VSBuffer } from '../../../base/common/buffer.js'; + +export interface IBrowserViewBounds { + windowId: number; + x: number; + y: number; + width: number; + height: number; + zoomFactor: number; +} + +export interface IBrowserViewCaptureScreenshotOptions { + quality?: number; + rect?: { x: number; y: number; width: number; height: number }; +} + +export interface IBrowserViewState { + url: string; + title: string; + canGoBack: boolean; + canGoForward: boolean; + loading: boolean; + isDevToolsOpen: boolean; + lastScreenshot: VSBuffer | undefined; + lastFavicon: string | undefined; + lastError: IBrowserViewLoadError | undefined; + storageScope: BrowserViewStorageScope; +} + +export interface IBrowserViewNavigationEvent { + url: string; + canGoBack: boolean; + canGoForward: boolean; +} + +export interface IBrowserViewLoadingEvent { + loading: boolean; + error?: IBrowserViewLoadError; +} + +export interface IBrowserViewLoadError { + url: string; + errorCode: number; + errorDescription: string; +} + +export interface IBrowserViewFocusEvent { + focused: boolean; +} + +export interface IBrowserViewDevToolsStateEvent { + isDevToolsOpen: boolean; +} + +export interface IBrowserViewKeyDownEvent { + key: string; + keyCode: number; + code: string; + ctrlKey: boolean; + shiftKey: boolean; + altKey: boolean; + metaKey: boolean; + repeat: boolean; +} + +export interface IBrowserViewTitleChangeEvent { + title: string; +} + +export interface IBrowserViewFaviconChangeEvent { + favicon: string; +} + +export interface IBrowserViewNewPageRequest { + url: string; + name?: string; + background: boolean; +} + +export enum BrowserViewStorageScope { + Global = 'global', + Workspace = 'workspace', + Ephemeral = 'ephemeral' +} + +export const ipcBrowserViewChannelName = 'browserView'; + +export interface IBrowserViewService { + /** + * Dynamic events that return an Event for a specific browser view ID. + */ + onDynamicDidNavigate(id: string): Event; + onDynamicDidChangeLoadingState(id: string): Event; + onDynamicDidChangeFocus(id: string): Event; + onDynamicDidChangeDevToolsState(id: string): Event; + onDynamicDidKeyCommand(id: string): Event; + onDynamicDidChangeTitle(id: string): Event; + onDynamicDidChangeFavicon(id: string): Event; + onDynamicDidRequestNewPage(id: string): Event; + onDynamicDidClose(id: string): Event; + + /** + * Get or create a browser view instance + * @param id The browser view identifier + * @param scope The storage scope for the browser view. Ignored if the view already exists. + * @param workspaceId Workspace identifier for session isolation. Only used if scope is 'workspace'. + */ + getOrCreateBrowserView(id: string, scope: BrowserViewStorageScope, workspaceId?: string): Promise; + + /** + * Destroy a browser view instance + * @param id The browser view identifier + */ + destroyBrowserView(id: string): Promise; + + /** + * Update the bounds of a browser view + * @param id The browser view identifier + * @param bounds The new bounds for the view + */ + layout(id: string, bounds: IBrowserViewBounds): Promise; + + /** + * Set the visibility of a browser view + * @param id The browser view identifier + * @param visible Whether the view should be visible + */ + setVisible(id: string, visible: boolean): Promise; + + /** + * Navigate the browser view to a URL + * @param id The browser view identifier + * @param url The URL to navigate to + */ + loadURL(id: string, url: string): Promise; + + /** + * Get the current URL of a browser view + * @param id The browser view identifier + */ + getURL(id: string): Promise; + + /** + * Go back in navigation history + * @param id The browser view identifier + */ + goBack(id: string): Promise; + + /** + * Go forward in navigation history + * @param id The browser view identifier + */ + goForward(id: string): Promise; + + /** + * Reload the current page + * @param id The browser view identifier + */ + reload(id: string): Promise; + + /** + * Toggle developer tools for the browser view. + * @param id The browser view identifier + */ + toggleDevTools(id: string): Promise; + + /** + * Check if the view can go back + * @param id The browser view identifier + */ + canGoBack(id: string): Promise; + + /** + * Check if the view can go forward + * @param id The browser view identifier + */ + canGoForward(id: string): Promise; + + /** + * Capture a screenshot of the browser view + * @param id The browser view identifier + * @param options Screenshot options (quality and rect) + * @returns Screenshot as a buffer + */ + captureScreenshot(id: string, options?: IBrowserViewCaptureScreenshotOptions): Promise; + + /** + * Dispatch a key event to the browser view + * @param id The browser view identifier + * @param keyEvent The key event data + */ + dispatchKeyEvent(id: string, keyEvent: IBrowserViewKeyDownEvent): Promise; + + /** + * Focus the browser view + * @param id The browser view identifier + */ + focus(id: string): Promise; + + /** + * Clear all storage data for the global browser session + */ + clearGlobalStorage(): Promise; + + /** + * Clear all storage data for a specific workspace browser session + * @param workspaceId The workspace identifier + */ + clearWorkspaceStorage(workspaceId: string): Promise; +} diff --git a/src/vs/platform/browserView/common/browserViewUri.ts b/src/vs/platform/browserView/common/browserViewUri.ts new file mode 100644 index 0000000000000..66ec58bd5d059 --- /dev/null +++ b/src/vs/platform/browserView/common/browserViewUri.ts @@ -0,0 +1,62 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Schemas } from '../../../base/common/network.js'; +import { URI } from '../../../base/common/uri.js'; +import { generateUuid } from '../../../base/common/uuid.js'; + +/** + * Helper for creating and parsing browser view URIs. + */ +export namespace BrowserViewUri { + + export const scheme = Schemas.vscodeBrowser; + + /** + * Creates a resource URI for a browser view with the given URL. + * Optionally accepts an ID; if not provided, a new UUID is generated. + */ + export function forUrl(url: string | undefined, id?: string): URI { + const viewId = id ?? generateUuid(); + return URI.from({ + scheme, + path: `/${viewId}`, + query: url ? `url=${encodeURIComponent(url)}` : undefined + }); + } + + /** + * Parses a browser view resource URI to extract the ID and URL. + */ + export function parse(resource: URI): { id: string; url: string } | undefined { + if (resource.scheme !== scheme) { + return undefined; + } + + // Remove leading slash if present + const id = resource.path.startsWith('/') ? resource.path.substring(1) : resource.path; + if (!id) { + return undefined; + } + + const url = resource.query ? new URLSearchParams(resource.query).get('url') ?? '' : ''; + + return { id, url }; + } + + /** + * Extracts the ID from a browser view resource URI. + */ + export function getId(resource: URI): string | undefined { + return parse(resource)?.id; + } + + /** + * Extracts the URL from a browser view resource URI. + */ + export function getUrl(resource: URI): string | undefined { + return parse(resource)?.url; + } +} diff --git a/src/vs/platform/browserView/electron-main/browserView.ts b/src/vs/platform/browserView/electron-main/browserView.ts new file mode 100644 index 0000000000000..7498771656006 --- /dev/null +++ b/src/vs/platform/browserView/electron-main/browserView.ts @@ -0,0 +1,530 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { WebContentsView, webContents } from 'electron'; +import { Disposable } from '../../../base/common/lifecycle.js'; +import { Emitter, Event } from '../../../base/common/event.js'; +import { VSBuffer } from '../../../base/common/buffer.js'; +import { IBrowserViewBounds, IBrowserViewDevToolsStateEvent, IBrowserViewFocusEvent, IBrowserViewKeyDownEvent, IBrowserViewState, IBrowserViewNavigationEvent, IBrowserViewLoadingEvent, IBrowserViewLoadError, IBrowserViewTitleChangeEvent, IBrowserViewFaviconChangeEvent, IBrowserViewNewPageRequest, BrowserViewStorageScope, IBrowserViewCaptureScreenshotOptions } from '../common/browserView.js'; +import { EVENT_KEY_CODE_MAP, KeyCode, SCAN_CODE_STR_TO_EVENT_KEY_CODE } from '../../../base/common/keyCodes.js'; +import { IThemeMainService } from '../../theme/electron-main/themeMainService.js'; +import { IWindowsMainService } from '../../windows/electron-main/windows.js'; +import { IBaseWindow, ICodeWindow } from '../../window/electron-main/window.js'; +import { IAuxiliaryWindowsMainService } from '../../auxiliaryWindow/electron-main/auxiliaryWindows.js'; +import { IAuxiliaryWindow } from '../../auxiliaryWindow/electron-main/auxiliaryWindow.js'; +import { ILogService } from '../../log/common/log.js'; + + +/** + * Represents a single browser view instance with its WebContentsView and all associated logic. + * This class encapsulates all operations and events for a single browser view. + */ +export class BrowserView extends Disposable { + private readonly _view: WebContentsView; + private readonly _faviconRequestCache = new Map>(); + + private _lastScreenshot: VSBuffer | undefined = undefined; + private _lastFavicon: string | undefined = undefined; + private _lastError: IBrowserViewLoadError | undefined = undefined; + + private _window: IBaseWindow | undefined; + private _isSendingKeyEvent = false; + + private readonly _onDidNavigate = this._register(new Emitter()); + readonly onDidNavigate: Event = this._onDidNavigate.event; + + private readonly _onDidChangeLoadingState = this._register(new Emitter()); + readonly onDidChangeLoadingState: Event = this._onDidChangeLoadingState.event; + + private readonly _onDidChangeFocus = this._register(new Emitter()); + readonly onDidChangeFocus: Event = this._onDidChangeFocus.event; + + private readonly _onDidChangeDevToolsState = this._register(new Emitter()); + readonly onDidChangeDevToolsState: Event = this._onDidChangeDevToolsState.event; + + private readonly _onDidKeyCommand = this._register(new Emitter()); + readonly onDidKeyCommand: Event = this._onDidKeyCommand.event; + + private readonly _onDidChangeTitle = this._register(new Emitter()); + readonly onDidChangeTitle: Event = this._onDidChangeTitle.event; + + private readonly _onDidChangeFavicon = this._register(new Emitter()); + readonly onDidChangeFavicon: Event = this._onDidChangeFavicon.event; + + private readonly _onDidRequestNewPage = this._register(new Emitter()); + readonly onDidRequestNewPage: Event = this._onDidRequestNewPage.event; + + private readonly _onDidClose = this._register(new Emitter()); + readonly onDidClose: Event = this._onDidClose.event; + + constructor( + viewSession: Electron.Session, + private readonly storageScope: BrowserViewStorageScope, + @IThemeMainService private readonly themeMainService: IThemeMainService, + @IWindowsMainService private readonly windowsMainService: IWindowsMainService, + @IAuxiliaryWindowsMainService private readonly auxiliaryWindowsMainService: IAuxiliaryWindowsMainService, + @ILogService private readonly logService: ILogService + ) { + super(); + + this._view = new WebContentsView({ + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + webviewTag: false, + session: viewSession + } + }); + + this._view.webContents.setWindowOpenHandler((details) => { + // For new tab requests, fire event for workbench to handle + if (details.disposition === 'background-tab' || details.disposition === 'foreground-tab') { + this._onDidRequestNewPage.fire({ + url: details.url, + name: details.frameName || undefined, + background: details.disposition === 'background-tab' + }); + return { action: 'deny' }; // Deny the default browser behavior since we're handling it + } + + // Deny other requests like new windows. + return { action: 'deny' }; + }); + + this._view.webContents.on('destroyed', () => { + this._onDidClose.fire(); + }); + + this.setupEventListeners(); + + // Create and register plugins for this web contents + this._register(new ThemePlugin(this._view, this.themeMainService, this.logService)); + } + + private setupEventListeners(): void { + const webContents = this._view.webContents; + + // DevTools state events + webContents.on('devtools-opened', () => { + this._onDidChangeDevToolsState.fire({ isDevToolsOpen: true }); + }); + + webContents.on('devtools-closed', () => { + this._onDidChangeDevToolsState.fire({ isDevToolsOpen: false }); + }); + + // Favicon events + webContents.on('page-favicon-updated', async (_event, favicons) => { + if (!favicons || favicons.length === 0) { + return; + } + + const found = favicons.find(f => this._faviconRequestCache.get(f)); + if (found) { + // already have a cached request for this favicon, use it + this._lastFavicon = await this._faviconRequestCache.get(found)!; + this._onDidChangeFavicon.fire({ favicon: this._lastFavicon }); + return; + } + + // try each url in order until one works + for (const url of favicons) { + const request = (async () => { + const response = await webContents.session.fetch(url, { + cache: 'force-cache' + }); + const type = await response.headers.get('content-type'); + const buffer = await response.arrayBuffer(); + + return `data:${type};base64,${Buffer.from(buffer).toString('base64')}`; + })(); + + this._faviconRequestCache.set(url, request); + + try { + this._lastFavicon = await request; + this._onDidChangeFavicon.fire({ favicon: this._lastFavicon }); + // On success, leave the promise in the cache and stop looping + return; + } catch (e) { + this._faviconRequestCache.delete(url); + // On failure, try the next one + } + } + }); + + // Title events + webContents.on('page-title-updated', (_event, title) => { + this._onDidChangeTitle.fire({ title }); + }); + + const fireNavigationEvent = () => { + this._onDidNavigate.fire({ + url: webContents.getURL(), + canGoBack: webContents.navigationHistory.canGoBack(), + canGoForward: webContents.navigationHistory.canGoForward() + }); + }; + + const fireLoadingEvent = (loading: boolean) => { + this._onDidChangeLoadingState.fire({ loading, error: this._lastError }); + }; + + // Loading state events + webContents.on('did-start-loading', () => { + this._lastError = undefined; + fireLoadingEvent(true); + }); + webContents.on('did-stop-loading', () => fireLoadingEvent(false)); + webContents.on('did-fail-load', (e, errorCode, errorDescription, validatedURL, isMainFrame) => { + if (isMainFrame) { + this._lastError = { + url: validatedURL, + errorCode, + errorDescription + }; + + fireLoadingEvent(false); + this._onDidNavigate.fire({ + url: validatedURL, + canGoBack: webContents.navigationHistory.canGoBack(), + canGoForward: webContents.navigationHistory.canGoForward() + }); + } + }); + webContents.on('did-finish-load', () => fireLoadingEvent(false)); + + webContents.on('render-process-gone', (_event, details) => { + this._lastError = { + url: webContents.getURL(), + errorCode: details.exitCode, + errorDescription: `Render process gone: ${details.reason}` + }; + + fireLoadingEvent(false); + }); + + // Navigation events (when URL actually changes) + webContents.on('did-navigate', fireNavigationEvent); + webContents.on('did-navigate-in-page', fireNavigationEvent); + + // Focus events + webContents.on('focus', () => { + this._onDidChangeFocus.fire({ focused: true }); + }); + + webContents.on('blur', () => { + this._onDidChangeFocus.fire({ focused: false }); + }); + + // Key down events - listen for raw key input events + webContents.on('before-input-event', async (event, input) => { + if (input.type === 'keyDown' && !this._isSendingKeyEvent) { + const eventKeyCode = SCAN_CODE_STR_TO_EVENT_KEY_CODE[input.code] || 0; + const keyCode = EVENT_KEY_CODE_MAP[eventKeyCode] || KeyCode.Unknown; + const hasCommandModifier = input.control || input.alt || input.meta; + const isNonEditingKey = + keyCode >= KeyCode.F1 && keyCode <= KeyCode.F24 || + keyCode >= KeyCode.AudioVolumeMute; + + if (hasCommandModifier || isNonEditingKey) { + event.preventDefault(); + this._onDidKeyCommand.fire({ + key: input.key, + keyCode: eventKeyCode, + code: input.code, + ctrlKey: input.control || false, + shiftKey: input.shift || false, + altKey: input.alt || false, + metaKey: input.meta || false, + repeat: input.isAutoRepeat || false + }); + } + } + }); + + // For now, always prevent sites from blocking unload. + // In the future we may want to show a dialog to ask the user, + // with heavy restrictions regarding interaction and repeated prompts. + webContents.on('will-prevent-unload', (e) => { + e.preventDefault(); + }); + } + + /** + * Get the current state of this browser view + */ + getState(): IBrowserViewState { + const webContents = this._view.webContents; + return { + url: webContents.getURL(), + title: webContents.getTitle(), + canGoBack: webContents.navigationHistory.canGoBack(), + canGoForward: webContents.navigationHistory.canGoForward(), + loading: webContents.isLoading(), + isDevToolsOpen: webContents.isDevToolsOpened(), + lastScreenshot: this._lastScreenshot, + lastFavicon: this._lastFavicon, + lastError: this._lastError, + storageScope: this.storageScope + }; + } + + /** + * Toggle developer tools for this browser view. + */ + toggleDevTools(): void { + this._view.webContents.toggleDevTools(); + } + + /** + * Update the layout bounds of this view + */ + layout(bounds: IBrowserViewBounds): void { + if (this._window?.win?.id !== bounds.windowId) { + const newWindow = this.windowById(bounds.windowId); + if (newWindow) { + this._window?.win?.contentView.removeChildView(this._view); + this._window = newWindow; + newWindow.win?.contentView.addChildView(this._view); + } + } + + this._view.webContents.setZoomFactor(bounds.zoomFactor); + this._view.setBounds({ + x: Math.round(bounds.x * bounds.zoomFactor), + y: Math.round(bounds.y * bounds.zoomFactor), + width: Math.round(bounds.width * bounds.zoomFactor), + height: Math.round(bounds.height * bounds.zoomFactor) + }); + } + + /** + * Set the visibility of this view + */ + setVisible(visible: boolean): void { + // If the view is focused, pass focus back to the window when hiding + if (!visible && this._view.webContents.isFocused()) { + this._window?.win?.webContents.focus(); + } + + this._view.setVisible(visible); + } + + /** + * Load a URL in this view + */ + async loadURL(url: string): Promise { + await this._view.webContents.loadURL(url); + } + + /** + * Get the current URL + */ + getURL(): string { + return this._view.webContents.getURL(); + } + + /** + * Navigate back in history + */ + goBack(): void { + if (this._view.webContents.navigationHistory.canGoBack()) { + this._view.webContents.navigationHistory.goBack(); + } + } + + /** + * Navigate forward in history + */ + goForward(): void { + if (this._view.webContents.navigationHistory.canGoForward()) { + this._view.webContents.navigationHistory.goForward(); + } + } + + /** + * Reload the current page + */ + reload(): void { + this._view.webContents.reload(); + } + + /** + * Check if the view can navigate back + */ + canGoBack(): boolean { + return this._view.webContents.navigationHistory.canGoBack(); + } + + /** + * Check if the view can navigate forward + */ + canGoForward(): boolean { + return this._view.webContents.navigationHistory.canGoForward(); + } + + /** + * Capture a screenshot of this view + */ + async captureScreenshot(options?: IBrowserViewCaptureScreenshotOptions): Promise { + const quality = options?.quality ?? 80; + const image = await this._view.webContents.capturePage(options?.rect, { + stayHidden: true, + stayAwake: true + }); + const buffer = image.toJPEG(quality); + const screenshot = VSBuffer.wrap(buffer); + // Only update _lastScreenshot if capturing the full view + if (!options?.rect) { + this._lastScreenshot = screenshot; + } + return screenshot; + } + + /** + * Dispatch a keyboard event to this view + */ + async dispatchKeyEvent(keyEvent: IBrowserViewKeyDownEvent): Promise { + const event: Electron.KeyboardInputEvent = { + type: 'keyDown', + keyCode: keyEvent.key, + modifiers: [] + }; + if (keyEvent.ctrlKey) { + event.modifiers!.push('control'); + } + if (keyEvent.shiftKey) { + event.modifiers!.push('shift'); + } + if (keyEvent.altKey) { + event.modifiers!.push('alt'); + } + if (keyEvent.metaKey) { + event.modifiers!.push('meta'); + } + this._isSendingKeyEvent = true; + try { + await this._view.webContents.sendInputEvent(event); + } finally { + this._isSendingKeyEvent = false; + } + } + + /** + * Set the zoom factor of this view + */ + async setZoomFactor(zoomFactor: number): Promise { + await this._view.webContents.setZoomFactor(zoomFactor); + } + + /** + * Focus this view + */ + async focus(): Promise { + this._view.webContents.focus(); + } + + /** + * Get the underlying WebContentsView + */ + getWebContentsView(): WebContentsView { + return this._view; + } + + override dispose(): void { + // Remove from parent window + this._window?.win?.contentView.removeChildView(this._view); + + // Clean up the view and all its event listeners + // Note: webContents.close() automatically removes all event listeners + this._view.webContents.close({ waitForBeforeUnload: false }); + + super.dispose(); + } + + + private windowById(windowId: number | undefined): ICodeWindow | IAuxiliaryWindow | undefined { + return this.codeWindowById(windowId) ?? this.auxiliaryWindowById(windowId); + } + + private codeWindowById(windowId: number | undefined): ICodeWindow | undefined { + if (typeof windowId !== 'number') { + return undefined; + } + + return this.windowsMainService.getWindowById(windowId); + } + + private auxiliaryWindowById(windowId: number | undefined): IAuxiliaryWindow | undefined { + if (typeof windowId !== 'number') { + return undefined; + } + + const contents = webContents.fromId(windowId); + if (!contents) { + return undefined; + } + + return this.auxiliaryWindowsMainService.getWindowByWebContents(contents); + } +} + +export class ThemePlugin extends Disposable { + private readonly _webContents: Electron.WebContents; + private _injectedCSSKey?: string; + + constructor( + private readonly _view: Electron.WebContentsView, + private readonly themeMainService: IThemeMainService, + private readonly logService: ILogService + ) { + super(); + this._webContents = _view.webContents; + + // Set view background to match editor background + this.applyBackgroundColor(); + + // Apply theme when page loads + this._webContents.on('did-finish-load', () => this.applyTheme()); + + // Update theme when VS Code theme changes + this._register(this.themeMainService.onDidChangeColorScheme(() => { + this.applyBackgroundColor(); + this.applyTheme(); + })); + } + + private applyBackgroundColor(): void { + const backgroundColor = this.themeMainService.getBackgroundColor(); + this._view.setBackgroundColor(backgroundColor); + } + + private async applyTheme(): Promise { + if (this._webContents.isDestroyed()) { + return; + } + + const colorScheme = this.themeMainService.getColorScheme().dark ? 'dark' : 'light'; + + try { + // Remove previous theme CSS if it exists + if (this._injectedCSSKey) { + await this._webContents.removeInsertedCSS(this._injectedCSSKey); + } + + // Insert new theme CSS + this._injectedCSSKey = await this._webContents.insertCSS(` + /* VS Code theme override */ + :root { + color-scheme: ${colorScheme}; + } + `); + } catch (error) { + this.logService.error('ThemePlugin: Failed to inject CSS', error); + } + } +} diff --git a/src/vs/platform/browserView/electron-main/browserViewMainService.ts b/src/vs/platform/browserView/electron-main/browserViewMainService.ts new file mode 100644 index 0000000000000..403ebc74399b8 --- /dev/null +++ b/src/vs/platform/browserView/electron-main/browserViewMainService.ts @@ -0,0 +1,221 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { session } from 'electron'; +import { Disposable, DisposableMap } from '../../../base/common/lifecycle.js'; +import { VSBuffer } from '../../../base/common/buffer.js'; +import { IBrowserViewBounds, IBrowserViewKeyDownEvent, IBrowserViewState, IBrowserViewService, BrowserViewStorageScope, IBrowserViewCaptureScreenshotOptions } from '../common/browserView.js'; +import { joinPath } from '../../../base/common/resources.js'; +import { IEnvironmentMainService } from '../../environment/electron-main/environmentMainService.js'; +import { createDecorator, IInstantiationService } from '../../instantiation/common/instantiation.js'; +import { BrowserView } from './browserView.js'; +import { generateUuid } from '../../../base/common/uuid.js'; + +export const IBrowserViewMainService = createDecorator('browserViewMainService'); + +export interface IBrowserViewMainService extends IBrowserViewService { + // Additional electron-specific methods can be added here if needed in the future +} + +// Same as webviews +const allowedPermissions = new Set([ + 'pointerLock', + 'notifications', + 'clipboard-read', + 'clipboard-sanitized-write' +]); + +export class BrowserViewMainService extends Disposable implements IBrowserViewMainService { + declare readonly _serviceBrand: undefined; + + /** + * Check if a webContents belongs to an integrated browser view + */ + private static readonly knownSessions = new WeakSet(); + static isBrowserViewWebContents(contents: Electron.WebContents): boolean { + return BrowserViewMainService.knownSessions.has(contents.session); + } + + private readonly browserViews = this._register(new DisposableMap()); + + constructor( + @IEnvironmentMainService private readonly environmentMainService: IEnvironmentMainService, + @IInstantiationService private readonly instantiationService: IInstantiationService + ) { + super(); + } + + /** + * Get the session for a browser view based on data storage setting and workspace + */ + private getSession(requestedScope: BrowserViewStorageScope, viewId?: string, workspaceId?: string): { + session: Electron.Session; + resolvedScope: BrowserViewStorageScope; + } { + switch (requestedScope) { + case 'global': + return { session: session.fromPartition('persist:vscode-browser'), resolvedScope: BrowserViewStorageScope.Global }; + case 'workspace': + if (workspaceId) { + const storage = joinPath(this.environmentMainService.workspaceStorageHome, workspaceId, 'browserStorage'); + return { session: session.fromPath(storage.fsPath), resolvedScope: BrowserViewStorageScope.Workspace }; + } + // fallthrough + case 'ephemeral': + default: + return { session: session.fromPartition(`vscode-browser-${viewId ?? generateUuid()}`), resolvedScope: BrowserViewStorageScope.Ephemeral }; + } + } + + private configureSession(viewSession: Electron.Session): void { + viewSession.setPermissionRequestHandler((_webContents, permission, callback) => { + return callback(allowedPermissions.has(permission)); + }); + viewSession.setPermissionCheckHandler((_webContents, permission, _origin) => { + return allowedPermissions.has(permission); + }); + } + + async getOrCreateBrowserView(id: string, scope: BrowserViewStorageScope, workspaceId?: string): Promise { + if (this.browserViews.has(id)) { + // Note: scope will be ignored if the view already exists. + // Browser views cannot be moved between sessions after creation. + const view = this.browserViews.get(id)!; + return view.getState(); + } + + const { session, resolvedScope } = this.getSession(scope, id, workspaceId); + this.configureSession(session); + BrowserViewMainService.knownSessions.add(session); + + const view = this.instantiationService.createInstance(BrowserView, session, resolvedScope); + this.browserViews.set(id, view); + + return view.getState(); + } + + /** + * Get a browser view or throw if not found + */ + private _getBrowserView(id: string): BrowserView { + const view = this.browserViews.get(id); + if (!view) { + throw new Error(`Browser view ${id} not found`); + } + return view; + } + + onDynamicDidNavigate(id: string) { + return this._getBrowserView(id).onDidNavigate; + } + + onDynamicDidChangeLoadingState(id: string) { + return this._getBrowserView(id).onDidChangeLoadingState; + } + + onDynamicDidChangeFocus(id: string) { + return this._getBrowserView(id).onDidChangeFocus; + } + + onDynamicDidChangeDevToolsState(id: string) { + return this._getBrowserView(id).onDidChangeDevToolsState; + } + + onDynamicDidKeyCommand(id: string) { + return this._getBrowserView(id).onDidKeyCommand; + } + + onDynamicDidChangeTitle(id: string) { + return this._getBrowserView(id).onDidChangeTitle; + } + + onDynamicDidChangeFavicon(id: string) { + return this._getBrowserView(id).onDidChangeFavicon; + } + + onDynamicDidRequestNewPage(id: string) { + return this._getBrowserView(id).onDidRequestNewPage; + } + + onDynamicDidClose(id: string) { + return this._getBrowserView(id).onDidClose; + } + + async destroyBrowserView(id: string): Promise { + this.browserViews.deleteAndDispose(id); + } + + async layout(id: string, bounds: IBrowserViewBounds): Promise { + return this._getBrowserView(id).layout(bounds); + } + + async setVisible(id: string, visible: boolean): Promise { + return this._getBrowserView(id).setVisible(visible); + } + + async loadURL(id: string, url: string): Promise { + return this._getBrowserView(id).loadURL(url); + } + + async getURL(id: string): Promise { + return this._getBrowserView(id).getURL(); + } + + async goBack(id: string): Promise { + return this._getBrowserView(id).goBack(); + } + + async goForward(id: string): Promise { + return this._getBrowserView(id).goForward(); + } + + async reload(id: string): Promise { + return this._getBrowserView(id).reload(); + } + + async toggleDevTools(id: string): Promise { + return this._getBrowserView(id).toggleDevTools(); + } + + async canGoBack(id: string): Promise { + return this._getBrowserView(id).canGoBack(); + } + + async canGoForward(id: string): Promise { + return this._getBrowserView(id).canGoForward(); + } + + async captureScreenshot(id: string, options?: IBrowserViewCaptureScreenshotOptions): Promise { + return this._getBrowserView(id).captureScreenshot(options); + } + + async dispatchKeyEvent(id: string, keyEvent: IBrowserViewKeyDownEvent): Promise { + return this._getBrowserView(id).dispatchKeyEvent(keyEvent); + } + + async setZoomFactor(id: string, zoomFactor: number): Promise { + return this._getBrowserView(id).setZoomFactor(zoomFactor); + } + + async focus(id: string): Promise { + return this._getBrowserView(id).focus(); + } + + async clearGlobalStorage(): Promise { + const { session, resolvedScope } = this.getSession(BrowserViewStorageScope.Global); + if (resolvedScope !== BrowserViewStorageScope.Global) { + throw new Error('Failed to resolve global storage session'); + } + await session.clearData(); + } + + async clearWorkspaceStorage(workspaceId: string): Promise { + const { session, resolvedScope } = this.getSession(BrowserViewStorageScope.Workspace, undefined, workspaceId); + if (resolvedScope !== BrowserViewStorageScope.Workspace) { + throw new Error('Failed to resolve workspace storage session'); + } + await session.clearData(); + } +} diff --git a/src/vs/workbench/contrib/browserView/common/browserView.ts b/src/vs/workbench/contrib/browserView/common/browserView.ts new file mode 100644 index 0000000000000..cb312c7d5a895 --- /dev/null +++ b/src/vs/workbench/contrib/browserView/common/browserView.ts @@ -0,0 +1,325 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; +import { VSBuffer } from '../../../../base/common/buffer.js'; +import { + IBrowserViewBounds, + IBrowserViewNavigationEvent, + IBrowserViewLoadingEvent, + IBrowserViewLoadError, + IBrowserViewFocusEvent, + IBrowserViewKeyDownEvent, + IBrowserViewTitleChangeEvent, + IBrowserViewFaviconChangeEvent, + IBrowserViewNewPageRequest, + IBrowserViewDevToolsStateEvent, + IBrowserViewService, + BrowserViewStorageScope, + IBrowserViewCaptureScreenshotOptions +} from '../../../../platform/browserView/common/browserView.js'; +import { IWorkspaceContextService, WorkbenchState } from '../../../../platform/workspace/common/workspace.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { isLocalhost } from '../../../../platform/tunnel/common/tunnel.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IWorkspaceTrustManagementService } from '../../../../platform/workspace/common/workspaceTrust.js'; + +type IntegratedBrowserNavigationEvent = { + navigationType: 'urlInput' | 'goBack' | 'goForward' | 'reload'; + isLocalhost: boolean; +}; + +type IntegratedBrowserNavigationClassification = { + navigationType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'How the navigation was triggered' }; + isLocalhost: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the URL is a localhost address' }; + owner: 'kycutler'; + comment: 'Tracks navigation patterns in integrated browser'; +}; + +export const IBrowserViewWorkbenchService = createDecorator('browserViewWorkbenchService'); + +/** + * Workbench-level service for browser views that provides model-based access to browser views. + * This service manages browser view models that proxy to the main process browser view service. + */ +export interface IBrowserViewWorkbenchService { + readonly _serviceBrand: undefined; + + /** + * Get or create a browser view model for the given ID + * @param id The browser view identifier + * @returns A browser view model that proxies to the main process + */ + getOrCreateBrowserViewModel(id: string): Promise; + + /** + * Clear all storage data for the global browser session + */ + clearGlobalStorage(): Promise; + + /** + * Clear all storage data for the current workspace browser session + */ + clearWorkspaceStorage(): Promise; +} + + +/** + * A browser view model that represents a single browser view instance in the workbench. + * This model proxies calls to the main process browser view service using its unique ID. + */ +export interface IBrowserViewModel extends IDisposable { + readonly id: string; + readonly url: string; + readonly title: string; + readonly favicon: string | undefined; + readonly screenshot: VSBuffer | undefined; + readonly loading: boolean; + readonly canGoBack: boolean; + readonly isDevToolsOpen: boolean; + readonly canGoForward: boolean; + readonly error: IBrowserViewLoadError | undefined; + + readonly storageScope: BrowserViewStorageScope; + + readonly onDidNavigate: Event; + readonly onDidChangeLoadingState: Event; + readonly onDidChangeFocus: Event; + readonly onDidChangeDevToolsState: Event; + readonly onDidKeyCommand: Event; + readonly onDidChangeTitle: Event; + readonly onDidChangeFavicon: Event; + readonly onDidRequestNewPage: Event; + readonly onDidClose: Event; + readonly onWillDispose: Event; + + initialize(): Promise; + + layout(bounds: IBrowserViewBounds): Promise; + setVisible(visible: boolean): Promise; + loadURL(url: string): Promise; + goBack(): Promise; + goForward(): Promise; + reload(): Promise; + toggleDevTools(): Promise; + captureScreenshot(options?: IBrowserViewCaptureScreenshotOptions): Promise; + dispatchKeyEvent(keyEvent: IBrowserViewKeyDownEvent): Promise; + focus(): Promise; +} + +export class BrowserViewModel extends Disposable implements IBrowserViewModel { + private _url: string = ''; + private _title: string = ''; + private _favicon: string | undefined = undefined; + private _screenshot: VSBuffer | undefined = undefined; + private _loading: boolean = false; + private _isDevToolsOpen: boolean = false; + private _canGoBack: boolean = false; + private _canGoForward: boolean = false; + private _error: IBrowserViewLoadError | undefined = undefined; + private _storageScope: BrowserViewStorageScope = BrowserViewStorageScope.Ephemeral; + + private readonly _onWillDispose = this._register(new Emitter()); + readonly onWillDispose: Event = this._onWillDispose.event; + + constructor( + readonly id: string, + private readonly browserViewService: IBrowserViewService, + @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, + @IWorkspaceTrustManagementService private readonly workspaceTrustManagementService: IWorkspaceTrustManagementService, + @ITelemetryService private readonly telemetryService: ITelemetryService, + @IConfigurationService private readonly configurationService: IConfigurationService + ) { + super(); + } + + get url(): string { return this._url; } + get title(): string { return this._title; } + get favicon(): string | undefined { return this._favicon; } + get loading(): boolean { return this._loading; } + get isDevToolsOpen(): boolean { return this._isDevToolsOpen; } + get canGoBack(): boolean { return this._canGoBack; } + get canGoForward(): boolean { return this._canGoForward; } + get screenshot(): VSBuffer | undefined { return this._screenshot; } + get error(): IBrowserViewLoadError | undefined { return this._error; } + get storageScope(): BrowserViewStorageScope { return this._storageScope; } + + get onDidNavigate(): Event { + return this.browserViewService.onDynamicDidNavigate(this.id); + } + + get onDidChangeLoadingState(): Event { + return this.browserViewService.onDynamicDidChangeLoadingState(this.id); + } + + get onDidChangeFocus(): Event { + return this.browserViewService.onDynamicDidChangeFocus(this.id); + } + + get onDidChangeDevToolsState(): Event { + return this.browserViewService.onDynamicDidChangeDevToolsState(this.id); + } + + get onDidKeyCommand(): Event { + return this.browserViewService.onDynamicDidKeyCommand(this.id); + } + + get onDidChangeTitle(): Event { + return this.browserViewService.onDynamicDidChangeTitle(this.id); + } + + get onDidChangeFavicon(): Event { + return this.browserViewService.onDynamicDidChangeFavicon(this.id); + } + + get onDidRequestNewPage(): Event { + return this.browserViewService.onDynamicDidRequestNewPage(this.id); + } + + get onDidClose(): Event { + return this.browserViewService.onDynamicDidClose(this.id); + } + + /** + * Initialize the model with the current state from the main process + */ + async initialize(): Promise { + const dataStorageSetting = this.configurationService.getValue( + 'workbench.browser.dataStorage' + ) ?? BrowserViewStorageScope.Global; + + // Wait for trust initialization before determining storage scope + await this.workspaceTrustManagementService.workspaceTrustInitialized; + const isWorkspaceUntrusted = + this.workspaceContextService.getWorkbenchState() !== WorkbenchState.EMPTY && + !this.workspaceTrustManagementService.isWorkspaceTrusted(); + + // Always use ephemeral sessions for untrusted workspaces + const dataStorage = isWorkspaceUntrusted ? BrowserViewStorageScope.Ephemeral : dataStorageSetting; + + const workspaceId = this.workspaceContextService.getWorkspace().id; + const state = await this.browserViewService.getOrCreateBrowserView(this.id, dataStorage, workspaceId); + + this._url = state.url; + this._title = state.title; + this._loading = state.loading; + this._isDevToolsOpen = state.isDevToolsOpen; + this._canGoBack = state.canGoBack; + this._canGoForward = state.canGoForward; + this._screenshot = state.lastScreenshot; + this._favicon = state.lastFavicon; + this._error = state.lastError; + this._storageScope = state.storageScope; + + // Set up state synchronization + this._register(this.onDidNavigate(e => { + // Clear favicon on navigation to a different host + if (URL.parse(e.url)?.host !== URL.parse(this._url)?.host) { + this._favicon = undefined; + } + + this._url = e.url; + this._canGoBack = e.canGoBack; + this._canGoForward = e.canGoForward; + })); + + this._register(this.onDidChangeLoadingState(e => { + this._loading = e.loading; + this._error = e.error; + })); + + this._register(this.onDidChangeDevToolsState(e => { + this._isDevToolsOpen = e.isDevToolsOpen; + })); + + this._register(this.onDidChangeTitle(e => { + this._title = e.title; + })); + + this._register(this.onDidChangeFavicon(e => { + this._favicon = e.favicon; + })); + } + + async layout(bounds: IBrowserViewBounds): Promise { + return this.browserViewService.layout(this.id, bounds); + } + + async setVisible(visible: boolean): Promise { + return this.browserViewService.setVisible(this.id, visible); + } + + async loadURL(url: string): Promise { + this.logNavigationTelemetry('urlInput', url); + return this.browserViewService.loadURL(this.id, url); + } + + async goBack(): Promise { + this.logNavigationTelemetry('goBack', this._url); + return this.browserViewService.goBack(this.id); + } + + async goForward(): Promise { + this.logNavigationTelemetry('goForward', this._url); + return this.browserViewService.goForward(this.id); + } + + async reload(): Promise { + this.logNavigationTelemetry('reload', this._url); + return this.browserViewService.reload(this.id); + } + + async toggleDevTools(): Promise { + return this.browserViewService.toggleDevTools(this.id); + } + + async captureScreenshot(options?: IBrowserViewCaptureScreenshotOptions): Promise { + const result = await this.browserViewService.captureScreenshot(this.id, options); + // Encode to data URL for display in UI + if (!options?.rect) { + this._screenshot = result; + } + return result; + } + + async dispatchKeyEvent(keyEvent: IBrowserViewKeyDownEvent): Promise { + return this.browserViewService.dispatchKeyEvent(this.id, keyEvent); + } + + async focus(): Promise { + return this.browserViewService.focus(this.id); + } + + /** + * Log navigation telemetry event + */ + private logNavigationTelemetry(navigationType: IntegratedBrowserNavigationEvent['navigationType'], url: string): void { + let localhost: boolean; + try { + localhost = isLocalhost(new URL(url).hostname); + } catch { + localhost = false; + } + + this.telemetryService.publicLog2( + 'integratedBrowser.navigation', + { + navigationType, + isLocalhost: localhost + } + ); + } + + override dispose(): void { + this._onWillDispose.fire(); + + // Clean up the browser view when the model is disposed + void this.browserViewService.destroyBrowserView(this.id); + + super.dispose(); + } +} diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts new file mode 100644 index 0000000000000..590290854865a --- /dev/null +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts @@ -0,0 +1,543 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/browser.css'; +import { localize } from '../../../../nls.js'; +import { $, addDisposableListener, disposableWindowInterval, EventType, scheduleAtNextAnimationFrame } from '../../../../base/browser/dom.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { RawContextKey, IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { MenuId } from '../../../../platform/actions/common/actions.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; +import { IEditorService } from '../../../services/editor/common/editorService.js'; +import { EditorPane } from '../../../browser/parts/editor/editorPane.js'; +import { IEditorOpenContext } from '../../../common/editor.js'; +import { BrowserEditorInput } from './browserEditorInput.js'; +import { BrowserViewUri } from '../../../../platform/browserView/common/browserViewUri.js'; +import { IBrowserViewModel } from '../../browserView/common/browserView.js'; +import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { IStorageService } from '../../../../platform/storage/common/storage.js'; +import { IBrowserViewKeyDownEvent, IBrowserViewNavigationEvent, IBrowserViewLoadError } from '../../../../platform/browserView/common/browserView.js'; +import { IEditorGroup } from '../../../services/editor/common/editorGroupsService.js'; +import { IEditorOptions } from '../../../../platform/editor/common/editor.js'; +import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; +import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js'; +import { BrowserOverlayManager } from './overlayManager.js'; +import { getZoomFactor, onDidChangeZoomLevel } from '../../../../base/browser/browser.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { WorkbenchHoverDelegate } from '../../../../platform/hover/browser/hover.js'; +import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; +import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; +import { encodeBase64, VSBuffer } from '../../../../base/common/buffer.js'; + +export const CONTEXT_BROWSER_CAN_GO_BACK = new RawContextKey('browserCanGoBack', false, localize('browser.canGoBack', "Whether the browser can go back")); +export const CONTEXT_BROWSER_CAN_GO_FORWARD = new RawContextKey('browserCanGoForward', false, localize('browser.canGoForward', "Whether the browser can go forward")); +export const CONTEXT_BROWSER_FOCUSED = new RawContextKey('browserFocused', true, localize('browser.editorFocused', "Whether the browser editor is focused")); +export const CONTEXT_BROWSER_STORAGE_SCOPE = new RawContextKey('browserStorageScope', '', localize('browser.storageScope', "The storage scope of the current browser view")); +export const CONTEXT_BROWSER_DEVTOOLS_OPEN = new RawContextKey('browserDevToolsOpen', false, localize('browser.devToolsOpen', "Whether developer tools are open for the current browser view")); + +class BrowserNavigationBar extends Disposable { + private readonly _urlInput: HTMLInputElement; + + constructor( + editor: BrowserEditor, + container: HTMLElement, + instantiationService: IInstantiationService, + scopedContextKeyService: IContextKeyService + ) { + super(); + + // Create hover delegate for toolbar buttons + const hoverDelegate = this._register( + instantiationService.createInstance( + WorkbenchHoverDelegate, + 'element', + undefined, + { position: { hoverPosition: HoverPosition.ABOVE } } + ) + ); + + // Create navigation toolbar (left side) with scoped context + const navContainer = $('.browser-nav-toolbar'); + const scopedInstantiationService = instantiationService.createChild(new ServiceCollection( + [IContextKeyService, scopedContextKeyService] + )); + const navToolbar = this._register(scopedInstantiationService.createInstance( + MenuWorkbenchToolBar, + navContainer, + MenuId.BrowserNavigationToolbar, + { + hoverDelegate, + highlightToggledItems: true, + // Render all actions inline regardless of group + toolbarOptions: { primaryGroup: () => true, useSeparatorsInPrimaryActions: true }, + menuOptions: { shouldForwardArgs: true } + } + )); + + // URL input + this._urlInput = $('input.browser-url-input'); + this._urlInput.type = 'text'; + this._urlInput.placeholder = localize('browser.urlPlaceholder', "Enter URL..."); + + // Create actions toolbar (right side) with scoped context + const actionsContainer = $('.browser-actions-toolbar'); + const actionsToolbar = this._register(scopedInstantiationService.createInstance( + MenuWorkbenchToolBar, + actionsContainer, + MenuId.BrowserActionsToolbar, + { + hoverDelegate, + highlightToggledItems: true, + toolbarOptions: { primaryGroup: 'actions' }, + menuOptions: { shouldForwardArgs: true } + } + )); + + navToolbar.context = editor; + actionsToolbar.context = editor; + + // Assemble layout: nav | url | actions + container.appendChild(navContainer); + container.appendChild(this._urlInput); + container.appendChild(actionsContainer); + + // Setup URL input handler + this._register(addDisposableListener(this._urlInput, EventType.KEY_DOWN, (e: KeyboardEvent) => { + if (e.key === 'Enter') { + const url = this._urlInput.value.trim(); + if (url) { + editor.navigateToUrl(url); + } + } + })); + } + + /** + * Update the navigation bar state from a navigation event + */ + updateFromNavigationEvent(event: IBrowserViewNavigationEvent): void { + // URL input is updated, action enablement is handled by context keys + this._urlInput.value = event.url; + } + + /** + * Focus the URL input and select all text + */ + focusUrlInput(): void { + this._urlInput.select(); + this._urlInput.focus(); + } + + clear(): void { + this._urlInput.value = ''; + } +} + +export class BrowserEditor extends EditorPane { + static readonly ID = 'workbench.editor.browser'; + + private _overlayVisible = false; + private _editorVisible = false; + private _currentKeyDownEvent: IBrowserViewKeyDownEvent | undefined; + + private _navigationBar!: BrowserNavigationBar; + private _browserContainer!: HTMLElement; + private _errorContainer!: HTMLElement; + private _canGoBackContext!: IContextKey; + private _canGoForwardContext!: IContextKey; + private _storageScopeContext!: IContextKey; + private _devToolsOpenContext!: IContextKey; + + private _model: IBrowserViewModel | undefined; + private readonly _inputDisposables = this._register(new DisposableStore()); + private overlayManager: BrowserOverlayManager | undefined; + + constructor( + group: IEditorGroup, + @ITelemetryService telemetryService: ITelemetryService, + @IThemeService themeService: IThemeService, + @IStorageService storageService: IStorageService, + @IKeybindingService private readonly keybindingService: IKeybindingService, + @ILogService private readonly logService: ILogService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IEditorService private readonly editorService: IEditorService + ) { + super(BrowserEditor.ID, group, telemetryService, themeService, storageService); + } + + protected override createEditor(parent: HTMLElement): void { + // Create scoped context key service for this editor instance + const contextKeyService = this._register(this.contextKeyService.createScoped(parent)); + + // Create window-specific overlay manager for this editor + this.overlayManager = this._register(new BrowserOverlayManager(this.window)); + + // Bind navigation capability context keys + this._canGoBackContext = CONTEXT_BROWSER_CAN_GO_BACK.bindTo(contextKeyService); + this._canGoForwardContext = CONTEXT_BROWSER_CAN_GO_FORWARD.bindTo(contextKeyService); + this._storageScopeContext = CONTEXT_BROWSER_STORAGE_SCOPE.bindTo(contextKeyService); + this._devToolsOpenContext = CONTEXT_BROWSER_DEVTOOLS_OPEN.bindTo(contextKeyService); + + // Currently this is always true since it is scoped to the editor container + CONTEXT_BROWSER_FOCUSED.bindTo(contextKeyService); + + // Create root container + const root = $('.browser-root'); + parent.appendChild(root); + + // Create toolbar with navigation buttons and URL input + const toolbar = $('.browser-toolbar'); + + // Create navigation bar widget with scoped context + this._navigationBar = this._register(new BrowserNavigationBar(this, toolbar, this.instantiationService, contextKeyService)); + + root.appendChild(toolbar); + + // Create browser container (stub element for positioning) + this._browserContainer = $('.browser-container'); + this._browserContainer.tabIndex = 0; // make focusable + root.appendChild(this._browserContainer); + + // Create error container (hidden by default) + this._errorContainer = $('.browser-error-container'); + this._errorContainer.style.display = 'none'; + this._browserContainer.appendChild(this._errorContainer); + + this._register(addDisposableListener(this._browserContainer, EventType.FOCUS, (event) => { + // When the browser container gets focus, make sure the browser view also gets focused. + // But only if focus was already in the workbench (and not e.g. clicking back into the workbench from the browser view). + if (event.relatedTarget && this._model && this.shouldShowView) { + void this._model.focus(); + } + })); + + this._register(addDisposableListener(this._browserContainer, EventType.BLUR, () => { + // When focus goes to another part of the workbench, make sure the workbench view becomes focused. + const focused = this.window.document.activeElement; + if (focused && focused !== this._browserContainer) { + this.window.focus(); + } + })); + } + + override async setInput(input: BrowserEditorInput, options: IEditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise { + await super.setInput(input, options, context, token); + if (token.isCancellationRequested) { + return; + } + + this._inputDisposables.clear(); + + // Resolve the browser view model from the input + this._model = await input.resolve(); + if (token.isCancellationRequested || this.input !== input) { + return; + } + + this._storageScopeContext.set(this._model.storageScope); + this._devToolsOpenContext.set(this._model.isDevToolsOpen); + + // Clean up on input disposal + this._inputDisposables.add(input.onWillDispose(() => { + this._model = undefined; + })); + + // Initialize UI state and context keys from model + this.updateNavigationState({ + url: this._model.url, + canGoBack: this._model.canGoBack, + canGoForward: this._model.canGoForward + }); + this.setBackgroundImage(this._model.screenshot); + + if (context.newInGroup) { + this._navigationBar.focusUrlInput(); + } + + // Listen to model events for UI updates + this._inputDisposables.add(this._model.onDidKeyCommand(keyEvent => { + // Handle like webview does - convert to webview KeyEvent format + this.handleKeyEventFromBrowserView(keyEvent); + })); + + this._inputDisposables.add(this._model.onDidNavigate((navEvent: IBrowserViewNavigationEvent) => { + this.group.pinEditor(this.input); // pin editor on navigation + + // Update navigation bar and context keys from model + this.updateNavigationState(navEvent); + })); + + this._inputDisposables.add(this._model.onDidChangeLoadingState(() => { + this.updateErrorDisplay(); + })); + + this._inputDisposables.add(this._model.onDidChangeFocus(({ focused }) => { + // When the view gets focused, make sure the container also has focus. + if (focused) { + this._browserContainer.focus(); + } + })); + + this._inputDisposables.add(this._model.onDidChangeDevToolsState(e => { + this._devToolsOpenContext.set(e.isDevToolsOpen); + })); + + this._inputDisposables.add(this._model.onDidRequestNewPage(({ url, name, background }) => { + type IntegratedBrowserNewPageRequestEvent = { + background: boolean; + }; + + type IntegratedBrowserNewPageRequestClassification = { + background: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether page was requested to open in background' }; + owner: 'kycutler'; + comment: 'Tracks new page requests from integrated browser'; + }; + + this.telemetryService.publicLog2( + 'integratedBrowser.newPageRequest', + { + background + } + ); + + // Open a new browser tab for the requested URL + const browserUri = BrowserViewUri.forUrl(url, name ? `${input.id}-${name}` : undefined); + this.editorService.openEditor({ + resource: browserUri, + options: { + pinned: true, + inactive: background + } + }, this.group); + })); + + this._inputDisposables.add(this.overlayManager!.onDidChangeOverlayState(() => { + this.checkOverlays(); + })); + + // Listen for zoom level changes and update browser view zoom factor + this._inputDisposables.add(onDidChangeZoomLevel(targetWindowId => { + if (targetWindowId === this.window.vscodeWindowId) { + this.layout(); + } + })); + // Capture screenshot periodically (once per second) to keep background updated + this._inputDisposables.add(disposableWindowInterval( + this.window, + () => this.capturePlaceholderSnapshot(), + 1000 + )); + + this.updateErrorDisplay(); + this.layout(); + await this._model.setVisible(this.shouldShowView); + + // Sometimes the element has not been inserted into the DOM yet. Ensure layout after next animation frame. + scheduleAtNextAnimationFrame(this.window, () => this.layout()); + } + + protected override setEditorVisible(visible: boolean): void { + this._editorVisible = visible; + this.updateVisibility(); + } + + private updateVisibility(): void { + if (this._model) { + // Blur the background image if the view is hidden due to an overlay. + this._browserContainer.classList.toggle('blur', this._editorVisible && this._overlayVisible && !this._model?.error); + void this._model.setVisible(this.shouldShowView); + } + } + + private get shouldShowView(): boolean { + return this._editorVisible && !this._overlayVisible && !this._model?.error; + } + + private checkOverlays(): void { + if (!this.overlayManager) { + return; + } + const hasOverlappingOverlay = this.overlayManager.isOverlappingWithOverlays(this._browserContainer); + if (hasOverlappingOverlay !== this._overlayVisible) { + this._overlayVisible = hasOverlappingOverlay; + this.updateVisibility(); + } + } + + private updateErrorDisplay(): void { + if (!this._model) { + return; + } + + const error: IBrowserViewLoadError | undefined = this._model.error; + if (error) { + // Show error display + this._errorContainer.style.display = 'flex'; + + while (this._errorContainer.firstChild) { + this._errorContainer.removeChild(this._errorContainer.firstChild); + } + + const errorContent = $('.browser-error-content'); + const errorTitle = $('.browser-error-title'); + errorTitle.textContent = localize('browser.loadErrorLabel', "Failed to Load Page"); + + const errorMessage = $('.browser-error-detail'); + const errorText = $('span'); + errorText.textContent = `${error.errorDescription} (${error.errorCode})`; + errorMessage.appendChild(errorText); + + const errorUrl = $('.browser-error-detail'); + const urlLabel = $('strong'); + urlLabel.textContent = localize('browser.errorUrlLabel', "URL:"); + const urlValue = $('code'); + urlValue.textContent = error.url; + errorUrl.appendChild(urlLabel); + errorUrl.appendChild(document.createTextNode(' ')); + errorUrl.appendChild(urlValue); + + errorContent.appendChild(errorTitle); + errorContent.appendChild(errorMessage); + errorContent.appendChild(errorUrl); + this._errorContainer.appendChild(errorContent); + + this.setBackgroundImage(undefined); + } else { + // Hide error display + this._errorContainer.style.display = 'none'; + this.setBackgroundImage(this._model.screenshot); + } + + this.updateVisibility(); + } + + async navigateToUrl(url: string): Promise { + if (this._model) { + this.group.pinEditor(this.input); // pin editor on navigation + + const scheme = URL.parse(url)?.protocol; + if (!scheme) { + // If no scheme provided, default to http (to support localhost etc -- sites will generally upgrade to https) + url = 'http://' + url; + } + + await this._model.loadURL(url); + } + } + + async goBack(): Promise { + return this._model?.goBack(); + } + + async goForward(): Promise { + return this._model?.goForward(); + } + + async reload(): Promise { + return this._model?.reload(); + } + + async toggleDevTools(): Promise { + return this._model?.toggleDevTools(); + } + + /** + * Update navigation state and context keys + */ + private updateNavigationState(event: IBrowserViewNavigationEvent): void { + // Update navigation bar UI + this._navigationBar.updateFromNavigationEvent(event); + + // Update context keys for command enablement + this._canGoBackContext.set(event.canGoBack); + this._canGoForwardContext.set(event.canGoForward); + } + + private setBackgroundImage(buffer: VSBuffer | undefined): void { + if (buffer) { + const dataUrl = `data:image/jpeg;base64,${encodeBase64(buffer)}`; + this._browserContainer.style.backgroundImage = `url('${dataUrl}')`; + } else { + this._browserContainer.style.backgroundImage = ''; + } + } + + /** + * Capture a screenshot of the current browser view to use as placeholder background + */ + private async capturePlaceholderSnapshot(): Promise { + if (this._model && !this._overlayVisible) { + try { + const buffer = await this._model.captureScreenshot({ quality: 80 }); + this.setBackgroundImage(buffer); + } catch (error) { + this.logService.error('BrowserEditor.capturePlaceholderSnapshot: Failed to capture screenshot', error); + } + } + } + + forwardCurrentEvent(): boolean { + if (this._currentKeyDownEvent && this._model) { + void this._model.dispatchKeyEvent(this._currentKeyDownEvent); + return true; + } + return false; + } + + private async handleKeyEventFromBrowserView(keyEvent: IBrowserViewKeyDownEvent): Promise { + this._currentKeyDownEvent = keyEvent; + + try { + const syntheticEvent = new KeyboardEvent('keydown', keyEvent); + const standardEvent = new StandardKeyboardEvent(syntheticEvent); + + const handled = this.keybindingService.dispatchEvent(standardEvent, this._browserContainer); + if (!handled) { + this.forwardCurrentEvent(); + } + } catch (error) { + this.logService.error('BrowserEditor.handleKeyEventFromBrowserView: Error dispatching key event', error); + } finally { + this._currentKeyDownEvent = undefined; + } + } + + override layout(): void { + if (this._model) { + this.checkOverlays(); + + const containerRect = this._browserContainer.getBoundingClientRect(); + void this._model.layout({ + windowId: this.group.windowId, + x: containerRect.left, + y: containerRect.top, + width: containerRect.width, + height: containerRect.height, + zoomFactor: getZoomFactor(this.window) + }); + } + } + + override clearInput(): void { + this._inputDisposables.clear(); + + void this._model?.setVisible(false); + this._model = undefined; + + this._canGoBackContext.reset(); + this._canGoForwardContext.reset(); + this._storageScopeContext.reset(); + this._devToolsOpenContext.reset(); + + this._navigationBar.clear(); + this.setBackgroundImage(undefined); + + super.clearInput(); + } +} diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditorInput.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserEditorInput.ts new file mode 100644 index 0000000000000..771c95491b416 --- /dev/null +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserEditorInput.ts @@ -0,0 +1,247 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { URI } from '../../../../base/common/uri.js'; +import { BrowserViewUri } from '../../../../platform/browserView/common/browserViewUri.js'; +import { EditorInputCapabilities, IEditorSerializer, IUntypedEditorInput } from '../../../common/editor.js'; +import { EditorInput } from '../../../common/editor/editorInput.js'; +import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { TAB_ACTIVE_FOREGROUND } from '../../../common/theme.js'; +import { localize } from '../../../../nls.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IBrowserViewWorkbenchService, IBrowserViewModel } from '../common/browserView.js'; +import { hasKey } from '../../../../base/common/types.js'; +import { ILifecycleService, ShutdownReason } from '../../../services/lifecycle/common/lifecycle.js'; +import { BrowserEditor } from './browserEditor.js'; + +const LOADING_SPINNER_SVG = (color: string | undefined) => ` + + + + + + +`; + +export interface IBrowserEditorInputData { + readonly id: string; + readonly url?: string; + readonly title?: string; + readonly favicon?: string; +} + +export class BrowserEditorInput extends EditorInput { + static readonly ID = 'workbench.editorinputs.browser'; + private static readonly DEFAULT_LABEL = localize('browser.editorLabel', "Browser"); + + private readonly _id: string; + private readonly _initialData: IBrowserEditorInputData; + private _model: IBrowserViewModel | undefined; + private _modelPromise: Promise | undefined; + + constructor( + options: IBrowserEditorInputData, + @IThemeService private readonly themeService: IThemeService, + @IBrowserViewWorkbenchService private readonly browserViewWorkbenchService: IBrowserViewWorkbenchService, + @ILifecycleService private readonly lifecycleService: ILifecycleService + ) { + super(); + this._id = options.id; + this._initialData = options; + + this._register(this.lifecycleService.onWillShutdown((e) => { + if (this._model) { + // For reloads, we simply hide / re-show the view. + if (e.reason === ShutdownReason.RELOAD) { + void this._model.setVisible(false); + } else { + this._model.dispose(); + this._model = undefined; + } + } + })); + } + + get id() { + return this._id; + } + + override async resolve(): Promise { + if (!this._model && !this._modelPromise) { + this._modelPromise = (async () => { + this._model = await this.browserViewWorkbenchService.getOrCreateBrowserViewModel(this._id); + this._modelPromise = undefined; + + // Set up cleanup when the model is disposed + this._register(this._model.onWillDispose(() => { + this._model = undefined; + })); + + // Auto-close editor when webcontents closes + this._register(this._model.onDidClose(() => { + this.dispose(); + })); + + // Listen for label-relevant changes to fire onDidChangeLabel + this._register(this._model.onDidChangeTitle(() => this._onDidChangeLabel.fire())); + this._register(this._model.onDidChangeFavicon(() => this._onDidChangeLabel.fire())); + this._register(this._model.onDidChangeLoadingState(() => this._onDidChangeLabel.fire())); + this._register(this._model.onDidNavigate(() => this._onDidChangeLabel.fire())); + + // Navigate to initial URL if provided + if (this._initialData.url && this._model.url !== this._initialData.url) { + void this._model.loadURL(this._initialData.url); + } + + return this._model; + })(); + } + return this._model || this._modelPromise!; + } + + override get typeId(): string { + return BrowserEditorInput.ID; + } + + override get editorId(): string { + return BrowserEditor.ID; + } + + override get capabilities(): EditorInputCapabilities { + return EditorInputCapabilities.Singleton | EditorInputCapabilities.Readonly; + } + + override get resource(): URI { + if (this._resourceBeforeDisposal) { + return this._resourceBeforeDisposal; + } + + const url = this._model?.url ?? this._initialData.url ?? ''; + return BrowserViewUri.forUrl(url, this._id); + } + + override getIcon(): ThemeIcon | URI | undefined { + // Use model data if available, otherwise fall back to initial data + if (this._model) { + if (this._model.loading) { + const color = this.themeService.getColorTheme().getColor(TAB_ACTIVE_FOREGROUND); + return URI.parse('data:image/svg+xml;utf8,' + encodeURIComponent(LOADING_SPINNER_SVG(color?.toString()))); + } + if (this._model.favicon) { + return URI.parse(this._model.favicon); + } + // Model exists but no favicon yet, use default + return Codicon.globe; + } + // Model not created yet, use initial data if available + if (this._initialData.favicon) { + return URI.parse(this._initialData.favicon); + } + return Codicon.globe; + } + + override getName(): string { + // Use model data if available, otherwise fall back to initial data + if (this._model && this._model.url) { + if (this._model.title) { + return this._model.title; + } + // Model exists, use its URL for authority + const authority = URI.parse(this._model.url).authority; + return authority || BrowserEditorInput.DEFAULT_LABEL; + } + // Model not created yet, use initial data + if (this._initialData.title) { + return this._initialData.title; + } + const url = this._initialData.url ?? ''; + const authority = URI.parse(url).authority; + return authority || BrowserEditorInput.DEFAULT_LABEL; + } + + override getDescription(): string | undefined { + // Use model URL if available, otherwise fall back to initial data + return this._model ? this._model.url : this._initialData.url; + } + + override canReopen(): boolean { + return true; + } + + override matches(otherInput: EditorInput | IUntypedEditorInput): boolean { + if (super.matches(otherInput)) { + return true; + } + + if (otherInput instanceof BrowserEditorInput) { + return this._id === otherInput._id; + } + + // Check if it's an untyped input with a browser view resource + if (hasKey(otherInput, { resource: true }) && otherInput.resource?.scheme === BrowserViewUri.scheme) { + const parsed = BrowserViewUri.parse(otherInput.resource); + if (parsed) { + return this._id === parsed.id; + } + } + + return false; + } + + override toUntyped(): IUntypedEditorInput { + return { + resource: this.resource, + options: { + override: BrowserEditorInput.ID + } + }; + } + + // When closing the editor, toUntyped() is called after dispose(). + // So we save a snapshot of the resource so we can still use it after the model is disposed. + private _resourceBeforeDisposal: URI | undefined; + override dispose(): void { + if (this._model) { + this._resourceBeforeDisposal = this.resource; + this._model.dispose(); + this._model = undefined; + } + super.dispose(); + } + + serialize(): IBrowserEditorInputData { + return { + id: this._id, + url: this._model ? this._model.url : this._initialData.url, + title: this._model ? this._model.title : this._initialData.title, + favicon: this._model ? this._model.favicon : this._initialData.favicon + }; + } +} + +export class BrowserEditorSerializer implements IEditorSerializer { + canSerialize(editorInput: EditorInput): editorInput is BrowserEditorInput { + return editorInput instanceof BrowserEditorInput; + } + + serialize(editorInput: EditorInput): string | undefined { + if (!this.canSerialize(editorInput)) { + return undefined; + } + + return JSON.stringify(editorInput.serialize()); + } + + deserialize(instantiationService: IInstantiationService, serializedEditor: string): EditorInput | undefined { + try { + const data: IBrowserEditorInputData = JSON.parse(serializedEditor); + return instantiationService.createInstance(BrowserEditorInput, data); + } catch { + return undefined; + } + } +} diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts new file mode 100644 index 0000000000000..5e4f20ed9be44 --- /dev/null +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts @@ -0,0 +1,147 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from '../../../../nls.js'; +import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { EditorPaneDescriptor, IEditorPaneRegistry } from '../../../browser/editor.js'; +import { EditorExtensions, IEditorFactoryRegistry } from '../../../common/editor.js'; +import { BrowserEditor } from './browserEditor.js'; +import { BrowserEditorInput, BrowserEditorSerializer } from './browserEditorInput.js'; +import { BrowserViewUri } from '../../../../platform/browserView/common/browserViewUri.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { IEditorService } from '../../../services/editor/common/editorService.js'; +import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js'; +import { IConfigurationRegistry, Extensions as ConfigurationExtensions, ConfigurationScope } from '../../../../platform/configuration/common/configurationRegistry.js'; +import { workbenchConfigurationNodeBase } from '../../../common/configuration.js'; +import { MultiCommand, RedoCommand, SelectAllCommand, UndoCommand } from '../../../../editor/browser/editorExtensions.js'; +import { CopyAction, CutAction, PasteAction } from '../../../../editor/contrib/clipboard/browser/clipboard.js'; +import { IEditorResolverService, RegisteredEditorPriority } from '../../../services/editor/common/editorResolverService.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; +import { Schemas } from '../../../../base/common/network.js'; +import { IBrowserViewWorkbenchService } from '../common/browserView.js'; +import { BrowserViewWorkbenchService } from './browserViewWorkbenchService.js'; + +// Register actions +import './browserViewActions.js'; +import { BrowserViewStorageScope } from '../../../../platform/browserView/common/browserView.js'; + +Registry.as(EditorExtensions.EditorPane).registerEditorPane( + EditorPaneDescriptor.create( + BrowserEditor, + BrowserEditor.ID, + localize('browser.editorLabel', "Browser") + ), + [ + new SyncDescriptor(BrowserEditorInput) + ] +); + +Registry.as(EditorExtensions.EditorFactory).registerEditorSerializer( + BrowserEditorInput.ID, + BrowserEditorSerializer +); + +class BrowserEditorResolverContribution implements IWorkbenchContribution { + static readonly ID = 'workbench.contrib.browserEditorResolver'; + + constructor( + @IEditorResolverService editorResolverService: IEditorResolverService, + @IInstantiationService instantiationService: IInstantiationService + ) { + editorResolverService.registerEditor( + `${Schemas.vscodeBrowser}:/**`, + { + id: BrowserEditorInput.ID, + label: localize('browser.editorLabel', "Browser"), + priority: RegisteredEditorPriority.exclusive + }, + { + canSupportResource: resource => resource.scheme === Schemas.vscodeBrowser, + singlePerResource: true + }, + { + createEditorInput: ({ resource, options }) => { + const parsed = BrowserViewUri.parse(resource); + if (!parsed) { + throw new Error(`Invalid browser view resource: ${resource.toString()}`); + } + + const browserInput = instantiationService.createInstance(BrowserEditorInput, { + id: parsed.id, + url: parsed.url + }); + + // Start resolving the input right away. This will create the browser view. + // This allows browser views to be loaded in the background. + void browserInput.resolve(); + + return { + editor: browserInput, + options: { + ...options, + pinned: !!parsed.url // pin if navigated + } + }; + } + } + ); + } +} + +registerWorkbenchContribution2(BrowserEditorResolverContribution.ID, BrowserEditorResolverContribution, WorkbenchPhase.BlockStartup); + +registerSingleton(IBrowserViewWorkbenchService, BrowserViewWorkbenchService, InstantiationType.Delayed); + +Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ + ...workbenchConfigurationNodeBase, + properties: { + 'workbench.browser.dataStorage': { + type: 'string', + enum: [ + BrowserViewStorageScope.Global, + BrowserViewStorageScope.Workspace, + BrowserViewStorageScope.Ephemeral + ], + markdownEnumDescriptions: [ + localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.dataStorage.global' }, 'All browser views share a single persistent session across all workspaces.'), + localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.dataStorage.workspace' }, 'Browser views within the same workspace share a persistent session.'), + localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.dataStorage.ephemeral' }, 'Each browser view has its own session that is cleaned up when closed.') + ], + restricted: true, + default: BrowserViewStorageScope.Global, + markdownDescription: localize( + { comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.dataStorage' }, + 'Controls how browser data (cookies, cache, storage) is shared between browser views.\n\n**Note**: In untrusted workspaces, this setting is ignored and `ephemeral` storage is always used.' + ), + scope: ConfigurationScope.WINDOW, + order: 100 + } + } +}); + +const PRIORITY = 100; + +function redirectCommandToBrowser(command: MultiCommand | undefined) { + command?.addImplementation(PRIORITY, 'integratedBrowser', (accessor: ServicesAccessor) => { + const editorService = accessor.get(IEditorService); + const activeEditor = editorService.activeEditorPane; + + if (activeEditor instanceof BrowserEditor) { + // This will return false if there is no event to forward + // (i.e., the command was not triggered from the browser view) + return activeEditor.forwardCurrentEvent(); + } + + return false; + }); +} + +redirectCommandToBrowser(UndoCommand); +redirectCommandToBrowser(RedoCommand); +redirectCommandToBrowser(SelectAllCommand); +redirectCommandToBrowser(CopyAction); +redirectCommandToBrowser(PasteAction); +redirectCommandToBrowser(CutAction); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts new file mode 100644 index 0000000000000..b3e409c365f67 --- /dev/null +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts @@ -0,0 +1,227 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize2 } from '../../../../nls.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { Action2, registerAction2, MenuId } from '../../../../platform/actions/common/actions.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { KeyMod, KeyCode } from '../../../../base/common/keyCodes.js'; +import { IEditorService } from '../../../services/editor/common/editorService.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { BrowserEditor, CONTEXT_BROWSER_CAN_GO_BACK, CONTEXT_BROWSER_CAN_GO_FORWARD, CONTEXT_BROWSER_DEVTOOLS_OPEN, CONTEXT_BROWSER_FOCUSED, CONTEXT_BROWSER_STORAGE_SCOPE } from './browserEditor.js'; +import { BrowserViewUri } from '../../../../platform/browserView/common/browserViewUri.js'; +import { IBrowserViewWorkbenchService } from '../common/browserView.js'; +import { BrowserViewStorageScope } from '../../../../platform/browserView/common/browserView.js'; + +// Context key expression to check if browser editor is active +const BROWSER_EDITOR_ACTIVE = ContextKeyExpr.equals('activeEditor', BrowserEditor.ID); + +const BrowserCategory = localize2('browserCategory', "Browser"); + +class OpenIntegratedBrowserAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.browser.open', + title: localize2('browser.openAction', "Open Integrated Browser"), + category: BrowserCategory, + f1: true + }); + } + + async run(accessor: ServicesAccessor, url?: string): Promise { + const editorService = accessor.get(IEditorService); + const resource = BrowserViewUri.forUrl(url); + + await editorService.openEditor({ resource }); + } +} + +class GoBackAction extends Action2 { + static readonly ID = 'workbench.action.browser.goBack'; + + constructor() { + super({ + id: GoBackAction.ID, + title: localize2('browser.goBackAction', 'Go Back'), + category: BrowserCategory, + icon: Codicon.arrowLeft, + f1: false, + menu: { + id: MenuId.BrowserNavigationToolbar, + group: 'navigation', + order: 1, + }, + precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_CAN_GO_BACK), + keybinding: { + when: BROWSER_EDITOR_ACTIVE, + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyMod.Alt | KeyCode.LeftArrow, + secondary: [KeyCode.BrowserBack], + mac: { primary: KeyMod.CtrlCmd | KeyCode.LeftArrow, secondary: [KeyCode.BrowserBack] } + } + }); + } + + async run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { + if (browserEditor instanceof BrowserEditor) { + await browserEditor.goBack(); + } + } +} + +class GoForwardAction extends Action2 { + static readonly ID = 'workbench.action.browser.goForward'; + + constructor() { + super({ + id: GoForwardAction.ID, + title: localize2('browser.goForwardAction', 'Go Forward'), + category: BrowserCategory, + icon: Codicon.arrowRight, + f1: false, + menu: { + id: MenuId.BrowserNavigationToolbar, + group: 'navigation', + order: 2, + when: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_CAN_GO_FORWARD) + }, + precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_CAN_GO_FORWARD), + keybinding: { + when: BROWSER_EDITOR_ACTIVE, + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyMod.Alt | KeyCode.RightArrow, + secondary: [KeyCode.BrowserForward], + mac: { primary: KeyMod.CtrlCmd | KeyCode.RightArrow, secondary: [KeyCode.BrowserForward] } + } + }); + } + + async run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { + if (browserEditor instanceof BrowserEditor) { + await browserEditor.goForward(); + } + } +} + +class ReloadAction extends Action2 { + static readonly ID = 'workbench.action.browser.reload'; + + constructor() { + super({ + id: ReloadAction.ID, + title: localize2('browser.reloadAction', 'Reload'), + category: BrowserCategory, + icon: Codicon.refresh, + f1: false, + menu: { + id: MenuId.BrowserNavigationToolbar, + group: 'navigation', + order: 3, + }, + precondition: BROWSER_EDITOR_ACTIVE, + keybinding: { + when: CONTEXT_BROWSER_FOCUSED, // Keybinding is only active when focus is within the browser editor + weight: KeybindingWeight.WorkbenchContrib + 50, // Priority over debug + primary: KeyCode.F5, + secondary: [KeyMod.CtrlCmd | KeyCode.KeyR], + mac: { primary: KeyCode.F5, secondary: [KeyMod.CtrlCmd | KeyCode.KeyR] } + } + }); + } + + async run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { + if (browserEditor instanceof BrowserEditor) { + await browserEditor.reload(); + } + } +} + +class ToggleDevToolsAction extends Action2 { + static readonly ID = 'workbench.action.browser.toggleDevTools'; + + constructor() { + super({ + id: ToggleDevToolsAction.ID, + title: localize2('browser.toggleDevToolsAction', 'Toggle Developer Tools'), + category: BrowserCategory, + icon: Codicon.tools, + f1: false, + toggled: ContextKeyExpr.equals(CONTEXT_BROWSER_DEVTOOLS_OPEN.key, true), + menu: { + id: MenuId.BrowserActionsToolbar, + group: 'actions', + order: 1, + when: BROWSER_EDITOR_ACTIVE + }, + precondition: BROWSER_EDITOR_ACTIVE + }); + } + + async run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { + if (browserEditor instanceof BrowserEditor) { + await browserEditor.toggleDevTools(); + } + } +} + +class ClearGlobalBrowserStorageAction extends Action2 { + static readonly ID = 'workbench.action.browser.clearGlobalStorage'; + + constructor() { + super({ + id: ClearGlobalBrowserStorageAction.ID, + title: localize2('browser.clearGlobalStorageAction', 'Clear Storage (Global)'), + category: BrowserCategory, + icon: Codicon.clearAll, + f1: true, + menu: { + id: MenuId.BrowserActionsToolbar, + group: 'storage', + order: 1, + when: ContextKeyExpr.equals(CONTEXT_BROWSER_STORAGE_SCOPE.key, BrowserViewStorageScope.Global) + } + }); + } + + async run(accessor: ServicesAccessor): Promise { + const browserViewWorkbenchService = accessor.get(IBrowserViewWorkbenchService); + await browserViewWorkbenchService.clearGlobalStorage(); + } +} + +class ClearWorkspaceBrowserStorageAction extends Action2 { + static readonly ID = 'workbench.action.browser.clearWorkspaceStorage'; + + constructor() { + super({ + id: ClearWorkspaceBrowserStorageAction.ID, + title: localize2('browser.clearWorkspaceStorageAction', 'Clear Storage (Workspace)'), + category: BrowserCategory, + icon: Codicon.clearAll, + f1: true, + menu: { + id: MenuId.BrowserActionsToolbar, + group: 'storage', + order: 2, + when: ContextKeyExpr.equals(CONTEXT_BROWSER_STORAGE_SCOPE.key, BrowserViewStorageScope.Workspace) + } + }); + } + + async run(accessor: ServicesAccessor): Promise { + const browserViewWorkbenchService = accessor.get(IBrowserViewWorkbenchService); + await browserViewWorkbenchService.clearWorkspaceStorage(); + } +} + +// Register actions +registerAction2(OpenIntegratedBrowserAction); +registerAction2(GoBackAction); +registerAction2(GoForwardAction); +registerAction2(ReloadAction); +registerAction2(ToggleDevToolsAction); +registerAction2(ClearGlobalBrowserStorageAction); +registerAction2(ClearWorkspaceBrowserStorageAction); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts new file mode 100644 index 0000000000000..68d2376c587d1 --- /dev/null +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts @@ -0,0 +1,57 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IBrowserViewService, ipcBrowserViewChannelName } from '../../../../platform/browserView/common/browserView.js'; +import { IBrowserViewWorkbenchService, IBrowserViewModel, BrowserViewModel } from '../common/browserView.js'; +import { IMainProcessService } from '../../../../platform/ipc/common/mainProcessService.js'; +import { ProxyChannel } from '../../../../base/parts/ipc/common/ipc.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { Event } from '../../../../base/common/event.js'; + +export class BrowserViewWorkbenchService implements IBrowserViewWorkbenchService { + declare readonly _serviceBrand: undefined; + + private readonly _browserViewService: IBrowserViewService; + private readonly _models = new Map(); + + constructor( + @IMainProcessService mainProcessService: IMainProcessService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService + ) { + const channel = mainProcessService.getChannel(ipcBrowserViewChannelName); + this._browserViewService = ProxyChannel.toService(channel); + } + + async getOrCreateBrowserViewModel(id: string): Promise { + let model = this._models.get(id); + if (model) { + return model; + } + + model = this.instantiationService.createInstance(BrowserViewModel, id, this._browserViewService); + this._models.set(id, model); + + // Initialize the model with current state + await model.initialize(); + + // Clean up model when disposed + Event.once(model.onWillDispose)(() => { + this._models.delete(id); + }); + + return model; + } + + async clearGlobalStorage(): Promise { + return this._browserViewService.clearGlobalStorage(); + } + + async clearWorkspaceStorage(): Promise { + const workspaceId = this.workspaceContextService.getWorkspace().id; + return this._browserViewService.clearWorkspaceStorage(workspaceId); + } +} diff --git a/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css b/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css new file mode 100644 index 0000000000000..24c5be8b5d89c --- /dev/null +++ b/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css @@ -0,0 +1,108 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.browser-root { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + + .browser-toolbar { + display: flex; + align-items: center; + padding: 8px; + border-bottom: 1px solid var(--vscode-editorWidget-border); + background-color: var(--vscode-editor-background); + flex-shrink: 0; + gap: 8px; + } + + .browser-nav-toolbar, + .browser-actions-toolbar { + display: flex; + align-items: center; + flex-shrink: 0; + } + + .browser-url-input { + flex: 1; + padding: 4px 8px; + background-color: var(--vscode-input-background); + color: var(--vscode-input-foreground); + border: 1px solid var(--vscode-input-border); + border-radius: 2px; + outline: none; + font-size: 13px; + + &:focus { + border-color: var(--vscode-focusBorder); + } + } + + .browser-container { + flex: 1; + min-height: 0; + margin: 0 2px 2px; + overflow: hidden; + position: relative; + background-image: none; + background-size: contain; + background-repeat: no-repeat; + filter: blur(0px); + transition: opacity 300ms ease-out, filter 300ms ease-out; + outline: none !important; + opacity: 1.0; + + &.blur { + opacity: 0.8; + filter: blur(2px); + } + } + + .browser-error-container { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + flex: 1; + display: flex; + align-items: flex-start; + justify-content: flex-start; + padding: 80px 40px; + margin: 0 2px 2px; + background-color: var(--vscode-editor-background); + } + + .browser-error-content { + max-width: 600px; + width: 100%; + } + + .browser-error-title { + font-size: 18px; + font-weight: 600; + color: var(--vscode-errorForeground); + margin-bottom: 20px; + } + + .browser-error-detail { + margin-bottom: 12px; + line-height: 1.6; + color: var(--vscode-foreground); + + strong { + font-weight: 600; + } + + code { + background-color: var(--vscode-textCodeBlock-background); + padding: 2px 6px; + border-radius: 3px; + font-family: var(--monaco-monospace-font); + font-size: 12px; + } + } +} diff --git a/src/vs/workbench/contrib/browserView/electron-browser/overlayManager.ts b/src/vs/workbench/contrib/browserView/electron-browser/overlayManager.ts new file mode 100644 index 0000000000000..0dc09718dade8 --- /dev/null +++ b/src/vs/workbench/contrib/browserView/electron-browser/overlayManager.ts @@ -0,0 +1,191 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { getDomNodePagePosition, IDomNodePagePosition } from '../../../../base/browser/dom.js'; +import { CodeWindow } from '../../../../base/browser/window.js'; + +const OVERLAY_CLASSES: string[] = [ + 'monaco-menu-container', + 'quick-input-widget', + 'monaco-hover', + 'monaco-dialog-modal-block', + 'notifications-center', + 'notification-toast-container', + 'context-view' +]; + +export const IBrowserOverlayManager = createDecorator('browserOverlayManager'); + +export interface IBrowserOverlayManager { + readonly _serviceBrand: undefined; + + /** + * Event fired when overlay state changes + */ + readonly onDidChangeOverlayState: Event; + + /** + * Check if the given element overlaps with any overlay + */ + isOverlappingWithOverlays(element: HTMLElement): boolean; +} + +export class BrowserOverlayManager extends Disposable implements IBrowserOverlayManager { + declare readonly _serviceBrand: undefined; + + private readonly _onDidChangeOverlayState = this._register(new Emitter({ + onWillAddFirstListener: () => { + // Start observing the document for structural changes + this._observerIsConnected = true; + this._structuralObserver.observe(this.targetWindow.document.body, { + childList: true, + subtree: true + }); + this.updateTrackedElements(); + }, + onDidRemoveLastListener: () => { + // Stop observing when no listeners are present + this._observerIsConnected = false; + this._structuralObserver.disconnect(); + this.stopTrackingElements(); + } + })); + readonly onDidChangeOverlayState = this._onDidChangeOverlayState.event; + + private readonly _overlayCollections = new Map>(); + private _overlayRectangles = new WeakMap(); + private _elementObservers = new WeakMap(); + private _structuralObserver: MutationObserver; + private _observerIsConnected: boolean = false; + + constructor( + private readonly targetWindow: CodeWindow + ) { + super(); + + // Initialize live collections for each overlay selector + for (const className of OVERLAY_CLASSES) { + // We need dynamic collections for overlay detection, using getElementsByClassName is intentional here + // eslint-disable-next-line no-restricted-syntax + this._overlayCollections.set(className, this.targetWindow.document.getElementsByClassName(className)); + } + + // Setup structural observer to watch for element additions/removals + this._structuralObserver = new MutationObserver((mutations) => { + let didRemove = false; + for (const mutation of mutations) { + for (const node of mutation.removedNodes) { + if (this._elementObservers.has(node as HTMLElement)) { + const observer = this._elementObservers.get(node as HTMLElement); + observer?.disconnect(); + this._elementObservers.delete(node as HTMLElement); + didRemove = true; + } + + if (this._overlayRectangles.delete(node as HTMLElement)) { + didRemove = true; + } + } + } + this.updateTrackedElements(didRemove); + }); + } + + private *overlays(): Iterable { + for (const collection of this._overlayCollections.values()) { + for (const element of collection) { + yield element as HTMLElement; + } + } + } + + private updateTrackedElements(shouldEmit = false): void { + // Scan all overlay collections for elements and ensure they have observers + for (const overlay of this.overlays()) { + // Create a new observer for this specific element if we don't already have one + if (!this._elementObservers.has(overlay)) { + const observer = new MutationObserver(() => { + this._overlayRectangles.delete(overlay); + this._onDidChangeOverlayState.fire(); + }); + + // Store the observer in the WeakMap + this._elementObservers.set(overlay, observer); + + // Start observing this element + observer.observe(overlay, { + attributes: true, + attributeFilter: ['style', 'class'], + childList: true, + subtree: true + }); + + shouldEmit = true; + } + } + + if (shouldEmit) { + this._onDidChangeOverlayState.fire(); + } + } + + private getRect(element: HTMLElement): IDomNodePagePosition { + if (!this._overlayRectangles.has(element)) { + const rect = getDomNodePagePosition(element); + // If the observer is not connected (no listeners), do not cache rectangles as we won't know when they change. + if (!this._observerIsConnected) { + return rect; + } + this._overlayRectangles.set(element, rect); + } + return this._overlayRectangles.get(element)!; + } + + isOverlappingWithOverlays(element: HTMLElement): boolean { + const elementRect = getDomNodePagePosition(element); + + // Check against all precomputed overlay rectangles + for (const overlay of this.overlays()) { + const overlayRect = this.getRect(overlay); + if (overlayRect && this.isRectanglesOverlapping(elementRect, overlayRect)) { + return true; + } + } + + return false; + } + + private isRectanglesOverlapping(rect1: IDomNodePagePosition, rect2: IDomNodePagePosition): boolean { + // If elements are offscreen or set to zero size, consider them non-overlapping + if (rect1.width === 0 || rect1.height === 0 || rect2.width === 0 || rect2.height === 0) { + return false; + } + + return !(rect1.left + rect1.width <= rect2.left || + rect2.left + rect2.width <= rect1.left || + rect1.top + rect1.height <= rect2.top || + rect2.top + rect2.height <= rect1.top); + } + + private stopTrackingElements(): void { + for (const overlay of this.overlays()) { + const observer = this._elementObservers.get(overlay); + observer?.disconnect(); + } + this._overlayRectangles = new WeakMap(); + this._elementObservers = new WeakMap(); + } + + override dispose(): void { + this._observerIsConnected = false; + this._structuralObserver.disconnect(); + this.stopTrackingElements(); + + super.dispose(); + } +} diff --git a/src/vs/workbench/workbench.desktop.main.ts b/src/vs/workbench/workbench.desktop.main.ts index 7a17b276d15b6..f242c4bd8ee16 100644 --- a/src/vs/workbench/workbench.desktop.main.ts +++ b/src/vs/workbench/workbench.desktop.main.ts @@ -155,6 +155,9 @@ import './contrib/externalTerminal/electron-browser/externalTerminal.contributio // Webview import './contrib/webview/electron-browser/webview.contribution.js'; +// Browser +import './contrib/browserView/electron-browser/browserView.contribution.js'; + // Splash import './contrib/splash/electron-browser/splash.contribution.js';