diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/browser.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/browser.test.ts index 0791391e6af24..2f2e41dad9a0c 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/browser.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/browser.test.ts @@ -73,6 +73,23 @@ import { assertNoRpc, closeAllEditors } from '../utils'; assert.strictEqual(window.browserTabs.length, countBefore - 1); }); + test('Closing via workbench.action.closeActiveEditor removes tab from browserTabs', async () => { + const tab = await window.openBrowserTab('about:blank'); + assert.ok(window.browserTabs.includes(tab)); + + const closed = new Promise(resolve => { + const disposable = window.onDidCloseBrowserTab(t => { + disposable.dispose(); + resolve(t); + }); + }); + + await commands.executeCommand('workbench.action.closeActiveEditor'); + const firedTab = await closed; + assert.ok(firedTab); + assert.ok(!window.browserTabs.includes(tab)); + }); + test('Can move a browser tab to a new group and close it successfully', async () => { const tab = await window.openBrowserTab('about:blank'); assert.ok(window.browserTabs.includes(tab)); diff --git a/src/vs/platform/browserView/common/browserView.ts b/src/vs/platform/browserView/common/browserView.ts index ecb40a9e856c0..af4f4acd46027 100644 --- a/src/vs/platform/browserView/common/browserView.ts +++ b/src/vs/platform/browserView/common/browserView.ts @@ -5,7 +5,6 @@ import { Event } from '../../../base/common/event.js'; import { VSBuffer } from '../../../base/common/buffer.js'; -import { UriComponents } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; const commandPrefix = 'workbench.action.browser'; @@ -81,6 +80,49 @@ export interface IBrowserViewCaptureScreenshotOptions { pageRect?: { x: number; y: number; width: number; height: number }; } +/** + * Identifies who owns a browser view's lifecycle. + * The owner is set at creation time and never changes. + */ +export interface IBrowserViewOwner { + /** The main code window ID that owns this view's lifecycle. */ + readonly mainWindowId: number; +} + +/** + * Summary information about a browser view, including its current state and + * ownership. Returned by the main service when listing or creating views. + */ +export interface IBrowserViewInfo { + readonly id: string; + readonly owner: IBrowserViewOwner; + readonly state: IBrowserViewState; +} + +/** + * Editor opening hints passed from the main process to the workbench. + */ +export interface IBrowserViewOpenOptions { + readonly preserveFocus?: boolean; + readonly background?: boolean; + readonly pinned?: boolean; + /** The parent view ID. Used by the workbench to place the new tab in the same editor group. */ + readonly parentViewId?: string; + /** When set, open in an auxiliary (new) window with these bounds. */ + readonly auxiliaryWindow?: { x?: number; y?: number; width?: number; height?: number }; +} + +export interface IBrowserViewCreatedEvent { + readonly info: IBrowserViewInfo; + readonly openOptions: IBrowserViewOpenOptions; +} + +export interface IBrowserViewCreateOptions { + readonly owner: IBrowserViewOwner; + readonly scope: BrowserViewStorageScope; + readonly initialState?: Partial; +} + export interface IBrowserViewState { url: string; title: string; @@ -161,19 +203,6 @@ export interface IBrowserViewFaviconChangeEvent { favicon: string | undefined; } -export enum BrowserNewPageLocation { - Foreground = 'foreground', - Background = 'background', - NewWindow = 'newWindow' -} -export interface IBrowserViewNewPageRequest { - resource: UriComponents; - url: string; - location: BrowserNewPageLocation; - // Only applicable if location is NewWindow - position?: { x?: number; y?: number; width?: number; height?: number }; -} - export interface IBrowserViewFindInPageOptions { recompute?: boolean; forward?: boolean; @@ -214,6 +243,11 @@ export function browserZoomAccessibilityLabel(zoomFactor: number): string { export const browserViewIsolatedWorldId = 999; export interface IBrowserViewService { + /** + * Fires when a new browser view is created from an internal source (e.g. CDP or window.open). + */ + onDidCreateBrowserView: Event; + /** * Dynamic events that return an Event for a specific browser view ID. */ @@ -225,17 +259,21 @@ export interface IBrowserViewService { onDynamicDidKeyCommand(id: string): Event; onDynamicDidChangeTitle(id: string): Event; onDynamicDidChangeFavicon(id: string): Event; - onDynamicDidRequestNewPage(id: string): Event; onDynamicDidFindInPage(id: string): Event; onDynamicDidClose(id: string): Event; /** - * Get or create a browser view instance + * Get all known browser views with their ownership and state information. + */ + getBrowserViews(windowId?: number): Promise; + + /** + * Get or create a browser view instance. Does not fire `onDidCreateBrowserView`. + * * @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'. + * @param options Creation options. If a view with the given ID already exists, these options are ignored. */ - getOrCreateBrowserView(id: string, scope: BrowserViewStorageScope, workspaceId?: string): Promise; + getOrCreateBrowserView(id: string, options: IBrowserViewCreateOptions): Promise; /** * Destroy a browser view instance diff --git a/src/vs/platform/browserView/electron-main/browserView.ts b/src/vs/platform/browserView/electron-main/browserView.ts index 2472e8bba1573..609f4b48de273 100644 --- a/src/vs/platform/browserView/electron-main/browserView.ts +++ b/src/vs/platform/browserView/electron-main/browserView.ts @@ -8,18 +8,24 @@ import { Disposable } from '../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { VSBuffer } from '../../../base/common/buffer.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; -import { IBrowserViewBounds, IBrowserViewDevToolsStateEvent, IBrowserViewFocusEvent, IBrowserViewKeyDownEvent, IBrowserViewState, IBrowserViewNavigationEvent, IBrowserViewLoadingEvent, IBrowserViewLoadError, IBrowserViewTitleChangeEvent, IBrowserViewFaviconChangeEvent, IBrowserViewNewPageRequest, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, IBrowserViewFindInPageResult, IBrowserViewVisibilityEvent, BrowserNewPageLocation, browserViewIsolatedWorldId, browserZoomFactors, browserZoomDefaultIndex, IElementData } from '../common/browserView.js'; +import { IBrowserViewBounds, IBrowserViewDevToolsStateEvent, IBrowserViewFocusEvent, IBrowserViewKeyDownEvent, IBrowserViewState, IBrowserViewNavigationEvent, IBrowserViewLoadingEvent, IBrowserViewLoadError, IBrowserViewTitleChangeEvent, IBrowserViewFaviconChangeEvent, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, IBrowserViewFindInPageResult, IBrowserViewVisibilityEvent, browserViewIsolatedWorldId, browserZoomFactors, browserZoomDefaultIndex, IElementData, IBrowserViewOwner, IBrowserViewOpenOptions } from '../common/browserView.js'; import { BrowserViewElementInspector } from './browserViewElementInspector.js'; import { IWindowsMainService } from '../../windows/electron-main/windows.js'; import { ICodeWindow } from '../../window/electron-main/window.js'; import { IAuxiliaryWindowsMainService } from '../../auxiliaryWindow/electron-main/auxiliaryWindows.js'; -import { BrowserViewUri } from '../common/browserViewUri.js'; import { BrowserViewDebugger } from './browserViewDebugger.js'; import { ILogService } from '../../log/common/log.js'; import { BrowserSession } from './browserSession.js'; import { IAuxiliaryWindow } from '../../auxiliaryWindow/electron-main/auxiliaryWindow.js'; -import { hasKey } from '../../../base/common/types.js'; import { SCAN_CODE_STR_TO_EVENT_KEY_CODE } from '../../../base/common/keyCodes.js'; +import { ITelemetryService } from '../../telemetry/common/telemetry.js'; +import { logBrowserOpen } from '../common/browserViewTelemetry.js'; + +enum NewPageLocation { + Foreground = 'foreground', + Background = 'background', + NewWindow = 'newWindow' +} /** * Represents a single browser view instance with its WebContentsView and all associated logic. @@ -37,7 +43,9 @@ export class BrowserView extends Disposable { readonly debugger: BrowserViewDebugger; private readonly _inspector: BrowserViewElementInspector; - private _window: ICodeWindow | IAuxiliaryWindow | undefined; + + private _ownerWindow: ICodeWindow; + private _currentWindow: ICodeWindow | IAuxiliaryWindow | undefined; private _isDisposed = false; private static readonly MAX_CONSOLE_LOG_ENTRIES = 1000; @@ -67,9 +75,6 @@ export class BrowserView extends Disposable { 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 _onDidFindInPage = this._register(new Emitter()); readonly onDidFindInPage: Event = this._onDidFindInPage.event; @@ -78,13 +83,15 @@ export class BrowserView extends Disposable { constructor( public readonly id: string, + public readonly owner: IBrowserViewOwner, public readonly session: BrowserSession, - createChildView: (options?: Electron.WebContentsViewConstructorOptions) => BrowserView, + createChildView: (url: string, electronOptions: Electron.WebContentsViewConstructorOptions | undefined, openOptions: IBrowserViewOpenOptions) => BrowserView, openContextMenu: (view: BrowserView, params: Electron.ContextMenuParams) => void, options: Electron.WebContentsViewConstructorOptions | undefined, @IWindowsMainService private readonly windowsMainService: IWindowsMainService, @IAuxiliaryWindowsMainService private readonly auxiliaryWindowsMainService: IAuxiliaryWindowsMainService, - @ILogService private readonly logService: ILogService + @ILogService private readonly logService: ILogService, + @ITelemetryService private readonly telemetryService: ITelemetryService, ) { super(); @@ -108,12 +115,18 @@ export class BrowserView extends Disposable { }); this._view.setBackgroundColor('#FFFFFF'); + this._ownerWindow = this.windowsMainService.getWindowById(owner.mainWindowId)!; + if (!this._ownerWindow) { + throw new Error(`Window with ID ${owner.mainWindowId} not found`); + } + this._register(this._ownerWindow.onDidClose(() => this.dispose())); + this._view.webContents.setWindowOpenHandler((details) => { const location = (() => { switch (details.disposition) { - case 'background-tab': return BrowserNewPageLocation.Background; - case 'foreground-tab': return BrowserNewPageLocation.Foreground; - case 'new-window': return BrowserNewPageLocation.NewWindow; + case 'background-tab': return NewPageLocation.Background; + case 'foreground-tab': return NewPageLocation.Foreground; + case 'new-window': return NewPageLocation.NewWindow; default: return undefined; } })(); @@ -126,15 +139,21 @@ export class BrowserView extends Disposable { return { action: 'allow', createWindow: (options) => { - const childView = createChildView(options); - const resource = BrowserViewUri.forId(childView.id); - - // Fire event for the workbench to open this view - this._onDidRequestNewPage.fire({ - resource, - url: details.url, - location, - position: { x: options.x, y: options.y, width: options.width, height: options.height } + logBrowserOpen(this.telemetryService, (() => { + switch (location) { + case NewPageLocation.NewWindow: return 'browserLinkNewWindow'; + case NewPageLocation.Background: return 'browserLinkBackground'; + case NewPageLocation.Foreground: return 'browserLinkForeground'; + } + })()); + + const childView = createChildView(details.url, options, { + pinned: true, + background: location === NewPageLocation.Background, + parentViewId: id, + auxiliaryWindow: location === NewPageLocation.NewWindow + ? { x: options.x, y: options.y, width: options.width, height: options.height } + : undefined, }); // Return the webContents so Electron can complete the window.open() call @@ -386,12 +405,12 @@ export class BrowserView extends Disposable { }); } - private consumePopupPermission(location: BrowserNewPageLocation): boolean { + private consumePopupPermission(location: NewPageLocation): boolean { switch (location) { - case BrowserNewPageLocation.Foreground: - case BrowserNewPageLocation.Background: + case NewPageLocation.Foreground: + case NewPageLocation.Background: return true; - case BrowserNewPageLocation.NewWindow: + case NewPageLocation.NewWindow: // Each user gesture allows one popup window within 1 second if (this._lastUserGestureTimestamp > Date.now() - 1000) { this._lastUserGestureTimestamp = -Infinity; @@ -442,11 +461,11 @@ export class BrowserView extends Disposable { * Update the layout bounds of this view */ layout(bounds: IBrowserViewBounds): void { - if (this._window?.win?.id !== bounds.windowId) { + if (this._currentWindow?.win?.id !== bounds.windowId) { const newWindow = this._windowById(bounds.windowId); if (newWindow) { - this._window?.win?.contentView.removeChildView(this._view); - this._window = newWindow; + this._currentWindow?.win?.contentView.removeChildView(this._view); + this._currentWindow = newWindow; newWindow.win?.contentView.addChildView(this._view); } } @@ -476,7 +495,7 @@ export class BrowserView extends Disposable { // 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._currentWindow?.win?.webContents.focus(); } this._view.setVisible(visible); @@ -596,7 +615,7 @@ export class BrowserView extends Disposable { */ async focus(force?: boolean): Promise { // By default, only focus the view if its window is already focused. - if (!force && !this._window?.win?.isFocused()) { + if (!force && !this._currentWindow?.win?.isFocused()) { return; } this._view.webContents.focus(); @@ -676,15 +695,7 @@ export class BrowserView extends Disposable { * This can be an auxiliary window, depending on where the view is currently hosted. */ getElectronWindow(): Electron.BrowserWindow | undefined { - return this._window?.win ?? undefined; - } - - /** - * Get the main code window hosting this browser view, if any. This is used for routing commands from the browser view to the correct window. - * If the browser view is hosted in an auxiliary window, this will return the parent code window of that auxiliary window. - */ - getTopCodeWindow(): ICodeWindow | undefined { - return this._window && hasKey(this._window, { parentId: true }) ? this._codeWindowById(this._window.parentId) : undefined; + return this._currentWindow?.win ?? undefined; } override dispose(): void { @@ -697,7 +708,7 @@ export class BrowserView extends Disposable { this.debugger.dispose(); // Remove from parent window - this._window?.win?.contentView.removeChildView(this._view); + this._currentWindow?.win?.contentView.removeChildView(this._view); // Fire close event BEFORE disposing emitters. This signals the view has been destroyed. this._onDidClose.fire(); diff --git a/src/vs/platform/browserView/electron-main/browserViewGroup.ts b/src/vs/platform/browserView/electron-main/browserViewGroup.ts index 901487e3f4ea2..7a903bae3f34e 100644 --- a/src/vs/platform/browserView/electron-main/browserViewGroup.ts +++ b/src/vs/platform/browserView/electron-main/browserViewGroup.ts @@ -202,7 +202,7 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I throw new Error(`Unknown browser context ${browserContextId}`); } - const target = await this.browserViewMainService.createTarget(url, browserContextId, windowId); + const target = await this.browserViewMainService.createTarget(url, windowId, browserContextId); if (target instanceof BrowserView) { await this.addView(target.id); return this.viewTargets.get(target.id)!; diff --git a/src/vs/platform/browserView/electron-main/browserViewMainService.ts b/src/vs/platform/browserView/electron-main/browserViewMainService.ts index e644063ae95c6..5c60f78d5f66e 100644 --- a/src/vs/platform/browserView/electron-main/browserViewMainService.ts +++ b/src/vs/platform/browserView/electron-main/browserViewMainService.ts @@ -3,17 +3,16 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Event } from '../../../base/common/event.js'; +import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable, DisposableMap } from '../../../base/common/lifecycle.js'; import { VSBuffer } from '../../../base/common/buffer.js'; import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js'; -import { IBrowserViewBounds, IBrowserViewState, IBrowserViewService, BrowserViewStorageScope, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, BrowserViewCommandId, IElementData } from '../common/browserView.js'; +import { IBrowserViewBounds, IBrowserViewState, IBrowserViewService, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, BrowserViewCommandId, IElementData, IBrowserViewOwner, IBrowserViewInfo, IBrowserViewCreatedEvent, IBrowserViewOpenOptions, IBrowserViewCreateOptions } from '../common/browserView.js'; import { clipboard, Menu, MenuItem } from 'electron'; 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'; -import { BrowserViewUri } from '../common/browserViewUri.js'; import { IWindowsMainService } from '../../windows/electron-main/windows.js'; import { BrowserSession } from './browserSession.js'; import { IApplicationStorageMainService } from '../../storage/electron-main/storageMainService.js'; @@ -21,7 +20,6 @@ import { IntegratedBrowserOpenSource, logBrowserOpen } from '../common/browserVi import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { localize } from '../../../nls.js'; import { INativeHostMainService } from '../../native/electron-main/nativeHostMainService.js'; -import { ITextEditorOptions } from '../../editor/common/editor.js'; import { htmlAttributeEncodeValue } from '../../../base/common/strings.js'; export const IBrowserViewMainService = createDecorator('browserViewMainService'); @@ -31,8 +29,8 @@ export interface IBrowserViewMainService extends IBrowserViewService { tryGetBrowserView(id: string): BrowserView | undefined; - /** Create a new target, open it in an editor, and return it. */ - createTarget(url: string, browserContextId?: string, windowId?: number): Promise; + /** Create a new target and return it. */ + createTarget(url: string, mainWindowId: number, browserContextId?: string): Promise; } export class BrowserViewMainService extends Disposable implements IBrowserViewMainService { @@ -50,6 +48,9 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa private readonly _activeTokens = new Map(); private _keybindings: { [commandId: string]: string } = Object.create(null); + private readonly _onDidCreateBrowserView = this._register(new Emitter()); + readonly onDidCreateBrowserView: Event = this._onDidCreateBrowserView.event; + constructor( @IEnvironmentMainService private readonly environmentMainService: IEnvironmentMainService, @IInstantiationService private readonly instantiationService: IInstantiationService, @@ -61,36 +62,48 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa super(); } - async getOrCreateBrowserView(id: string, scope: BrowserViewStorageScope, workspaceId?: string): Promise { + async getOrCreateBrowserView(id: string, options: IBrowserViewCreateOptions): 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. + // Note: options will be ignored if the view already exists. const view = this.browserViews.get(id)!; return view.getState(); } + const ownerWindow = this.windowsMainService.getWindowById(options.owner.mainWindowId); + if (!ownerWindow) { + throw new Error(`Owner window with ID ${options.owner.mainWindowId} not found`); + } + const browserSession = BrowserSession.getOrCreate( id, - scope, + options.scope, this.environmentMainService.workspaceStorageHome, - workspaceId + ownerWindow.openedWorkspace?.id ); - const view = this.createBrowserView(id, browserSession); - return view.getState(); + const view = this.createBrowserView(id, options.owner, browserSession); + + if (options.initialState?.url) { + void view.loadURL(options.initialState.url); + } + + return { + ...view.getState(), + ...options.initialState + }; } tryGetBrowserView(id: string): BrowserView | undefined { return this.browserViews.get(id); } - async createTarget(url: string, browserContextId?: string, windowId?: number): Promise { + async createTarget(url: string, mainWindowId: number, browserContextId?: string): Promise { const browserSession = browserContextId ? BrowserSession.get(browserContextId) : undefined; return this.openNew(url, { + owner: { mainWindowId }, session: browserSession, - windowId, - editorOptions: { preserveFocus: true }, + openOptions: { preserveFocus: true }, source: 'cdpCreated' }); } @@ -106,6 +119,25 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa return view; } + private _getViewInfo(view: BrowserView): IBrowserViewInfo { + return { + id: view.id, + owner: view.owner, + state: view.getState() + }; + } + + async getBrowserViews(windowId?: number): Promise { + const result: IBrowserViewInfo[] = []; + for (const [, view] of this.browserViews) { + if (windowId !== undefined && view.owner.mainWindowId !== windowId) { + continue; + } + result.push(this._getViewInfo(view)); + } + return result; + } + onDynamicDidNavigate(id: string) { return this._getBrowserView(id).onDidNavigate; } @@ -138,10 +170,6 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa return this._getBrowserView(id).onDidChangeFavicon; } - onDynamicDidRequestNewPage(id: string) { - return this._getBrowserView(id).onDidRequestNewPage; - } - onDynamicDidFindInPage(id: string) { return this._getBrowserView(id).onDidFindInPage; } @@ -283,7 +311,7 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa /** * Create a browser view backed by the given {@link BrowserSession}. */ - private createBrowserView(id: string, browserSession: BrowserSession, options?: Electron.WebContentsViewConstructorOptions): BrowserView { + private createBrowserView(id: string, owner: IBrowserViewOwner, browserSession: BrowserSession, options?: Electron.WebContentsViewConstructorOptions): BrowserView { if (this.browserViews.has(id)) { throw new Error(`Browser view with id ${id} already exists`); } @@ -293,9 +321,24 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa const view = this.instantiationService.createInstance( BrowserView, id, + owner, browserSession, - // Recursive factory for nested windows (child views share the same session) - (childOptions) => this.createBrowserView(generateUuid(), browserSession, childOptions), + // Recursive factory for nested windows (child views share the same session and owner). + (url, electronOptions, openOptions) => { + const child = this.createBrowserView(generateUuid(), owner, browserSession, electronOptions); + + if (url) { + void child.loadURL(url).catch(() => { }); + } + + const info = this._getViewInfo(child); + this._onDidCreateBrowserView.fire({ + info: url ? { ...info, state: { ...info.state, url } } : info, + openOptions + }); + + return child; + }, (v, params) => this.showContextMenu(v, params), options ); @@ -311,32 +354,31 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa private async openNew( url: string, { + owner, session, - windowId, - editorOptions, + openOptions, source }: { + owner: IBrowserViewOwner; session: BrowserSession | undefined; - windowId: number | undefined; - editorOptions: ITextEditorOptions; + openOptions: IBrowserViewOpenOptions; source: IntegratedBrowserOpenSource; } ): Promise { const targetId = generateUuid(); - const view = this.createBrowserView(targetId, session || BrowserSession.getOrCreateEphemeral(targetId)); + const view = this.createBrowserView(targetId, owner, session || BrowserSession.getOrCreateEphemeral(targetId)); - const window = windowId !== undefined ? this.windowsMainService.getWindowById(windowId) : this.windowsMainService.getFocusedWindow(); - if (!window) { - throw new Error(`Window ${windowId} not found`); + if (url) { + void view.loadURL(url).catch(() => { }); } - logBrowserOpen(this.telemetryService, source); - // Request the workbench to open the editor - window.sendWhenReady('vscode:runAction', CancellationToken.None, { - id: '_workbench.open', - args: [BrowserViewUri.forId(targetId), [undefined, { ...editorOptions, viewState: { url } }], undefined] + // Fire creation event so the workbench can open an editor tab + const info = this._getViewInfo(view); + this._onDidCreateBrowserView.fire({ + info: url ? { ...info, state: { ...info.state, url } } : info, + openOptions }); return view; @@ -358,9 +400,9 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa label: localize('browser.contextMenu.openLinkInNewTab', 'Open Link in New Tab'), click: () => { void this.openNew(params.linkURL, { + owner: view.owner, session: view.session, - windowId: view.getTopCodeWindow()?.id, - editorOptions: { preserveFocus: true, inactive: true }, + openOptions: { preserveFocus: true, background: true }, source: 'browserLinkBackground' }); } @@ -389,9 +431,9 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa label: localize('browser.contextMenu.openImageInNewTab', 'Open Image in New Tab'), click: () => { void this.openNew(params.srcURL!, { + owner: view.owner, session: view.session, - windowId: view.getTopCodeWindow()?.id, - editorOptions: { preserveFocus: true, inactive: true }, + openOptions: { preserveFocus: true, background: true }, source: 'browserLinkBackground' }); } diff --git a/src/vs/workbench/api/browser/mainThreadBrowsers.ts b/src/vs/workbench/api/browser/mainThreadBrowsers.ts index 2c0e0b7875b15..e3debe8ddb0f0 100644 --- a/src/vs/workbench/api/browser/mainThreadBrowsers.ts +++ b/src/vs/workbench/api/browser/mainThreadBrowsers.ts @@ -7,7 +7,7 @@ import { Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } import { IEditorService } from '../../services/editor/common/editorService.js'; import { IExtHostContext, extHostNamedCustomer } from '../../services/extensions/common/extHostCustomers.js'; import { BrowserTabDto, ExtHostBrowsersShape, ExtHostContext, MainContext, MainThreadBrowsersShape } from '../common/extHost.protocol.js'; -import { IBrowserViewCDPService } from '../../contrib/browserView/common/browserView.js'; +import { IBrowserViewCDPService, IBrowserViewWorkbenchService } from '../../contrib/browserView/common/browserView.js'; import { BrowserViewUri } from '../../../platform/browserView/common/browserViewUri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import { EditorGroupColumn, columnToEditorGroup } from '../../services/editor/common/editorGroupColumn.js'; @@ -29,25 +29,24 @@ export class MainThreadBrowsers extends Disposable implements MainThreadBrowsers extHostContext: IExtHostContext, @IEditorService private readonly editorService: IEditorService, @IBrowserViewCDPService private readonly cdpService: IBrowserViewCDPService, + @IBrowserViewWorkbenchService private readonly browserViewService: IBrowserViewWorkbenchService, @IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService, @IConfigurationService private readonly configurationService: IConfigurationService, ) { super(); this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostBrowsers); - // Track open browser editors - this._register(this.editorService.onWillOpenEditor((e) => { - if (e.editor instanceof BrowserEditorInput) { - this._track(e.editor); + // Track open browser editors via the workbench service + this._register(this.browserViewService.onDidChangeBrowserViews(() => { + for (const editor of this.browserViewService.getKnownBrowserViews().values()) { + this._track(editor); } })); this._register(this.editorService.onDidActiveEditorChange(() => this._syncActiveBrowserTab())); // Initial sync - for (const input of this.editorService.editors) { - if (input instanceof BrowserEditorInput) { - this._track(input); - } + for (const editor of this.browserViewService.getKnownBrowserViews().values()) { + this._track(editor); } this._syncActiveBrowserTab(); } diff --git a/src/vs/workbench/contrib/browserView/browser/browserView.contribution.ts b/src/vs/workbench/contrib/browserView/browser/browserView.contribution.ts index a16c153f57eda..51fa4d8732953 100644 --- a/src/vs/workbench/contrib/browserView/browser/browserView.contribution.ts +++ b/src/vs/workbench/contrib/browserView/browser/browserView.contribution.ts @@ -7,18 +7,28 @@ import { registerSingleton, InstantiationType } from '../../../../platform/insta import { IBrowserViewWorkbenchService, IBrowserViewCDPService, IBrowserViewModel } from '../common/browserView.js'; import { Event } from '../../../../base/common/event.js'; import { CDPEvent, CDPRequest, CDPResponse } from '../../../../platform/browserView/common/cdp/types.js'; +import { IBrowserViewState } from '../../../../platform/browserView/common/browserView.js'; +import { BrowserEditorInput } from '../common/browserEditorInput.js'; class WebBrowserViewWorkbenchService implements IBrowserViewWorkbenchService { declare readonly _serviceBrand: undefined; - async getOrCreateBrowserViewModel(_id: string): Promise { - throw new Error('Integrated Browser is not available in web.'); + readonly onDidChangeBrowserViews = Event.None; + + private readonly _known = new Map(); + + getKnownBrowserViews(): Map { + return this._known; } - async getBrowserViewModel(_id: string): Promise { + getOrCreateLazy(_id: string, _state: IBrowserViewState): BrowserEditorInput { throw new Error('Integrated Browser is not available in web.'); } + getBrowserViewModel(_id: string): IBrowserViewModel | undefined { + return undefined; + } + async clearGlobalStorage(): Promise { } async clearWorkspaceStorage(): Promise { } } diff --git a/src/vs/workbench/contrib/browserView/common/browserEditorInput.ts b/src/vs/workbench/contrib/browserView/common/browserEditorInput.ts index 5ab9c4265f68d..77cc549c182a7 100644 --- a/src/vs/workbench/contrib/browserView/common/browserEditorInput.ts +++ b/src/vs/workbench/contrib/browserView/common/browserEditorInput.ts @@ -9,19 +9,19 @@ import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { BrowserViewUri } from '../../../../platform/browserView/common/browserViewUri.js'; -import { IBrowserEditorViewState } from './browserView.js'; +import { IBrowserEditorViewState, IBrowserViewWorkbenchService } from './browserView.js'; import { EditorInputCapabilities, IEditorSerializer, IUntypedEditorInput, Verbosity } 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 { IBrowserViewModel } from '../common/browserView.js'; import { hasKey } from '../../../../base/common/types.js'; -import { ILifecycleService, ShutdownReason } from '../../../services/lifecycle/common/lifecycle.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { logBrowserOpen } from '../../../../platform/browserView/common/browserViewTelemetry.js'; import { LRUCachedFunction } from '../../../../base/common/cache.js'; +import { DisposableStore } from '../../../../base/common/lifecycle.js'; const LOADING_SPINNER_SVG = (color: string | undefined) => ` @@ -51,32 +51,53 @@ export class BrowserEditorInput extends EditorInput { private readonly _id: string; private _initialData: IBrowserEditorInputData; + private _model: IBrowserViewModel | undefined; private _modelPromise: Promise | undefined; + private _modelStore = this._register(new DisposableStore()); constructor( options: IBrowserEditorInputData, + private _resolveModel: () => Promise, @IThemeService private readonly themeService: IThemeService, - @IBrowserViewWorkbenchService private readonly browserViewWorkbenchService: IBrowserViewWorkbenchService, - @ILifecycleService private readonly lifecycleService: ILifecycleService, @IInstantiationService private readonly instantiationService: IInstantiationService, @ITelemetryService private readonly telemetryService: ITelemetryService ) { 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 model(): IBrowserViewModel | undefined { + return this._model; + } + + set model(model: IBrowserViewModel) { + if (this._model === model) { + return; + } + + this._modelStore.clear(); + this._model = model; + + // Set up cleanup when the model is disposed + this._modelStore.add(this._model.onWillDispose(() => { + this._modelStore.clear(); + this._model = undefined; })); + + // Auto-close editor when webcontents closes + this._modelStore.add(this._model.onDidClose(() => { + this.dispose(); + })); + + // Listen for label-relevant changes to fire onDidChangeLabel + this._modelStore.add(this._model.onDidChangeTitle(() => this._onDidChangeLabel.fire())); + this._modelStore.add(this._model.onDidChangeFavicon(() => this._onDidChangeLabel.fire())); + this._modelStore.add(this._model.onDidChangeLoadingState(() => this._onDidChangeLabel.fire())); + this._modelStore.add(this._model.onDidNavigate(() => this._onDidChangeLabel.fire())); + + this._onDidChangeLabel.fire(); } get id() { @@ -114,32 +135,9 @@ export class BrowserEditorInput extends EditorInput { override async resolve(): Promise { if (!this._model && !this._modelPromise) { this._modelPromise = (async () => { - this._model = await this.browserViewWorkbenchService.getOrCreateBrowserViewModel(this._id); + this._model = await this._resolveModel(); 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.setInitialURL(this._initialData.url, this._initialData.title, this._initialData.favicon); - } - - this._onDidChangeLabel.fire(); - return this._model; })(); } @@ -263,11 +261,13 @@ export class BrowserEditorInput extends EditorInput { override copy(): EditorInput { logBrowserOpen(this.telemetryService, 'copyToNewWindow'); - return this.instantiationService.createInstance(BrowserEditorInput, { - id: generateUuid(), - url: this.url, - title: this.title, - favicon: this.favicon + return this.instantiationService.invokeFunction((accessor) => { + const browserViewWorkbenchService = accessor.get(IBrowserViewWorkbenchService); + return browserViewWorkbenchService.getOrCreateLazy(generateUuid(), { + url: this.url, + title: this.title, + favicon: this.favicon + }); }); } @@ -327,7 +327,10 @@ export class BrowserEditorSerializer implements IEditorSerializer { deserialize(instantiationService: IInstantiationService, serializedEditor: string): EditorInput | undefined { try { const data: IBrowserEditorInputData = JSON.parse(serializedEditor); - return instantiationService.createInstance(BrowserEditorInput, data); + return instantiationService.invokeFunction((accessor) => { + const browserViewWorkbenchService = accessor.get(IBrowserViewWorkbenchService); + return browserViewWorkbenchService.getOrCreateLazy(data.id, data); + }); } catch { return undefined; } diff --git a/src/vs/workbench/contrib/browserView/common/browserView.ts b/src/vs/workbench/contrib/browserView/common/browserView.ts index 849efe3775c19..fcf544b9db45d 100644 --- a/src/vs/workbench/contrib/browserView/common/browserView.ts +++ b/src/vs/workbench/contrib/browserView/common/browserView.ts @@ -14,6 +14,7 @@ import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { localize } from '../../../../nls.js'; import { IPlaywrightService } from '../../../../platform/browserView/common/playwrightService.js'; +import type { BrowserEditorInput } from './browserEditorInput.js'; import { IBrowserViewBounds, IBrowserViewNavigationEvent, @@ -23,7 +24,6 @@ import { IBrowserViewKeyDownEvent, IBrowserViewTitleChangeEvent, IBrowserViewFaviconChangeEvent, - IBrowserViewNewPageRequest, IBrowserViewDevToolsStateEvent, IBrowserViewService, BrowserViewStorageScope, @@ -33,15 +33,15 @@ import { IBrowserViewVisibilityEvent, IBrowserViewCertificateError, IElementData, + IBrowserViewOwner, browserZoomDefaultIndex, - browserZoomFactors + browserZoomFactors, + IBrowserViewState } from '../../../../platform/browserView/common/browserView.js'; -import { IWorkspaceContextService, WorkbenchState } from '../../../../platform/workspace/common/workspace.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { isLocalhostAuthority } from '../../../../platform/url/common/trustedDomains.js'; -import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { IWorkspaceTrustManagementService } from '../../../../platform/workspace/common/workspaceTrust.js'; import { IAgentNetworkFilterService } from '../../../../platform/networkFilter/common/networkFilterService.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; import { IBrowserZoomService } from './browserZoomService.js'; /** Extracts the host from a URL string for zoom tracking purposes. */ @@ -97,19 +97,20 @@ 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 + * Fires when the set of known browser views changes. */ - getOrCreateBrowserViewModel(id: string): Promise; + readonly onDidChangeBrowserViews: Event; /** - * Get an existing browser view model for the given ID - * @param id The browser view identifier - * @returns A browser view model that proxies to the main process - * @throws If no browser view exists for the given ID + * Get all known browser views. */ - getBrowserViewModel(id: string): Promise; + getKnownBrowserViews(): Map; + + /** + * Get an existing browser view for the given ID, or create a new one if it doesn't exist. + * The underlying browser view is not created until the editor is opened or the model is resolved. + */ + getOrCreateLazy(id: string, initialState?: IBrowserEditorViewState): BrowserEditorInput; /** * Clear all storage data for the global browser session @@ -159,6 +160,7 @@ export interface IBrowserViewCDPService { */ export interface IBrowserViewModel extends IDisposable { readonly id: string; + readonly owner: IBrowserViewOwner; readonly url: string; readonly title: string; readonly favicon: string | undefined; @@ -186,15 +188,11 @@ export interface IBrowserViewModel extends IDisposable { readonly onDidKeyCommand: Event; readonly onDidChangeTitle: Event; readonly onDidChangeFavicon: Event; - readonly onDidRequestNewPage: Event; readonly onDidFindInPage: Event; readonly onDidChangeVisibility: Event; readonly onDidClose: Event; readonly onWillDispose: Event; - initialize(create: boolean): Promise; - setInitialURL(url: string, title?: string, favicon?: string): void; - layout(bounds: IBrowserViewBounds): Promise; setVisible(visible: boolean): Promise; loadURL(url: string): Promise; @@ -249,129 +247,49 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { constructor( readonly id: string, + readonly owner: IBrowserViewOwner, + initialState: IBrowserViewState, private readonly browserViewService: IBrowserViewService, - @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, - @IWorkspaceTrustManagementService private readonly workspaceTrustManagementService: IWorkspaceTrustManagementService, @ITelemetryService private readonly telemetryService: ITelemetryService, - @IConfigurationService private readonly configurationService: IConfigurationService, @IPlaywrightService private readonly playwrightService: IPlaywrightService, @IDialogService private readonly dialogService: IDialogService, @IStorageService private readonly storageService: IStorageService, @IBrowserZoomService private readonly zoomService: IBrowserZoomService, @IAgentNetworkFilterService private readonly agentNetworkFilterService: IAgentNetworkFilterService, + @ILogService private readonly logService: ILogService, ) { 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 focused(): boolean { return this._focused; } - get visible(): boolean { return this._visible; } - 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 certificateError(): IBrowserViewCertificateError | undefined { return this._certificateError; } - get storageScope(): BrowserViewStorageScope { return this._storageScope; } - get sharedWithAgent(): boolean { return this._sharedWithAgent; } - get zoomFactor(): number { return browserZoomFactors[this._browserZoomIndex]; } - get canZoomIn(): boolean { return this._browserZoomIndex < browserZoomFactors.length - 1; } - get canZoomOut(): boolean { return this._browserZoomIndex > 0; } - - 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 onDidFindInPage(): Event { - return this.browserViewService.onDynamicDidFindInPage(this.id); - } - - get onDidChangeVisibility(): Event { - return this.browserViewService.onDynamicDidChangeVisibility(this.id); - } - - get onDidClose(): Event { - return this.browserViewService.onDynamicDidClose(this.id); - } - - /** - * Initialize the model with the current state from the main process. - * @param create Whether to create the browser view if it doesn't already exist. - * @throws If the browser view doesn't exist and `create` is false, or if initialization fails - */ - async initialize(create: boolean): 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 = create - ? await this.browserViewService.getOrCreateBrowserView(this.id, dataStorage, workspaceId) - : await this.browserViewService.getState(this.id); - - this._url = state.url; - this._title = state.title; - this._loading = state.loading; - this._focused = state.focused; - this._visible = state.visible; - 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._certificateError = state.certificateError; - this._storageScope = state.storageScope; - this._sharedWithAgent = await this.playwrightService.isPageTracked(this.id); - this._browserZoomIndex = state.browserZoomIndex; + // Initialize state + this._url = initialState.url; + this._title = initialState.title; + this._loading = initialState.loading; + this._focused = initialState.focused; + this._visible = initialState.visible; + this._isDevToolsOpen = initialState.isDevToolsOpen; + this._canGoBack = initialState.canGoBack; + this._canGoForward = initialState.canGoForward; + this._screenshot = initialState.lastScreenshot; + this._favicon = initialState.lastFavicon; + this._error = initialState.lastError; + this._certificateError = initialState.certificateError; + this._storageScope = initialState.storageScope; + this._browserZoomIndex = initialState.browserZoomIndex; this._isEphemeral = this._storageScope === BrowserViewStorageScope.Ephemeral; this._zoomHost = parseZoomHost(this._url); + // Sync initial zoom and sharing state (async, but emits events) const effectiveZoomIndex = this.zoomService.getEffectiveZoomIndex(this._zoomHost, this._isEphemeral); if (effectiveZoomIndex !== this._browserZoomIndex) { - await this.setBrowserZoomIndex(effectiveZoomIndex); + void this.setBrowserZoomIndex(effectiveZoomIndex).catch(e => { + this.logService.warn(`[BrowserViewModel] Failed to set initial zoom:`, e); + }); } + void this.playwrightService.isPageTracked(this.id).then(shared => this._setSharedWithAgent(shared)).catch(e => { + this.logService.warn(`[BrowserViewModel] Failed to check initial page tracking:`, e); + }); + + // Set up state synchronization this._register(this.zoomService.onDidChangeZoom(({ host, isEphemeralChange }) => { if (isEphemeralChange && !this._isEphemeral) { @@ -380,12 +298,10 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { if (host === undefined || host === this._zoomHost) { void this.setBrowserZoomIndex( this.zoomService.getEffectiveZoomIndex(this._zoomHost, this._isEphemeral) - ); + ).catch(() => { }); } })); - // 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) { @@ -437,17 +353,62 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { })); } - setInitialURL(url: string, title?: string, favicon?: string): void { - if (this._url !== url) { - this._url = url; - this._title = title || ''; - this._favicon = favicon; - this._loading = true; - this._error = undefined; - this._certificateError = undefined; + 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 focused(): boolean { return this._focused; } + get visible(): boolean { return this._visible; } + 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 certificateError(): IBrowserViewCertificateError | undefined { return this._certificateError; } + get storageScope(): BrowserViewStorageScope { return this._storageScope; } + get sharedWithAgent(): boolean { return this._sharedWithAgent; } + get zoomFactor(): number { return browserZoomFactors[this._browserZoomIndex]; } + get canZoomIn(): boolean { return this._browserZoomIndex < browserZoomFactors.length - 1; } + get canZoomOut(): boolean { return this._browserZoomIndex > 0; } - void this.loadURL(url); // Non-blocking - } + 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 onDidFindInPage(): Event { + return this.browserViewService.onDynamicDidFindInPage(this.id); + } + + get onDidChangeVisibility(): Event { + return this.browserViewService.onDynamicDidChangeVisibility(this.id); + } + + get onDidClose(): Event { + return this.browserViewService.onDynamicDidClose(this.id); } async layout(bounds: IBrowserViewBounds): Promise { diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts index 4d4af6bf91e40..4b7789a3dee78 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts @@ -14,15 +14,14 @@ import { RawContextKey, IContextKey, IContextKeyService } from '../../../../plat import { MenuId } from '../../../../platform/actions/common/actions.js'; import { IInstantiationService, IConstructorSignature, BrandedService } from '../../../../platform/instantiation/common/instantiation.js'; import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; -import { AUX_WINDOW_GROUP, IEditorService } from '../../../services/editor/common/editorService.js'; import { EditorPane } from '../../../browser/parts/editor/editorPane.js'; import { IEditorOpenContext } from '../../../common/editor.js'; import { BrowserEditorInput } from '../common/browserEditorInput.js'; -import { IBrowserEditorViewState, IBrowserViewModel } from '../../browserView/common/browserView.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, IBrowserViewCertificateError, BrowserNewPageLocation } from '../../../../platform/browserView/common/browserView.js'; +import { IBrowserViewKeyDownEvent, IBrowserViewNavigationEvent, IBrowserViewLoadError, IBrowserViewCertificateError } 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'; @@ -39,10 +38,9 @@ import { ChatContextKeys } from '../../chat/common/actions/chatContextKeys.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { encodeBase64, VSBuffer } from '../../../../base/common/buffer.js'; import { SiteInfoWidget } from './siteInfoWidget.js'; -import { logBrowserOpen } from '../../../../platform/browserView/common/browserViewTelemetry.js'; -import { URI } from '../../../../base/common/uri.js'; import { Emitter } from '../../../../base/common/event.js'; import { ILayoutService } from '../../../../platform/layout/browser/layoutService.js'; +import { ILifecycleService, ShutdownReason } from '../../../services/lifecycle/common/lifecycle.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")); @@ -376,10 +374,17 @@ export class BrowserEditor extends EditorPane { @ILogService private readonly logService: ILogService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IContextKeyService private readonly contextKeyService: IContextKeyService, - @IEditorService private readonly editorService: IEditorService, @ILayoutService private readonly layoutService: ILayoutService, + @ILifecycleService private readonly lifecycleService: ILifecycleService, ) { super(BrowserEditorInput.EDITOR_ID, group, telemetryService, themeService, storageService); + + // Be sure to hide the view when the workbench is reloading, as `clearInput()` may not be called. + this._register(this.lifecycleService.onWillShutdown((e) => { + if (e.reason === ShutdownReason.RELOAD) { + this._model?.setVisible(false); + } + })); } protected override createEditor(parent: HTMLElement): void { @@ -598,31 +603,6 @@ export class BrowserEditor extends EditorPane { } })); - this._inputDisposables.add(this._model.onDidRequestNewPage(({ resource, url, location, position }) => { - logBrowserOpen(this.telemetryService, (() => { - switch (location) { - case BrowserNewPageLocation.Background: return 'browserLinkBackground'; - case BrowserNewPageLocation.Foreground: return 'browserLinkForeground'; - case BrowserNewPageLocation.NewWindow: return 'browserLinkNewWindow'; - } - })()); - - const targetGroup = location === BrowserNewPageLocation.NewWindow ? AUX_WINDOW_GROUP : this.group; - const viewState: IBrowserEditorViewState = { url }; - this.editorService.openEditor({ - resource: URI.revive(resource), - options: { - pinned: true, - inactive: location === BrowserNewPageLocation.Background, - auxiliary: { - bounds: position, - compact: true - }, - viewState - } - }, targetGroup); - })); - this._inputDisposables.add(this.overlayManager!.onDidChangeOverlayState(() => { this.checkOverlays(); })); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts index be1e6c7d1cb58..e8fc1087cc783 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts @@ -11,7 +11,6 @@ import { EditorExtensions, IEditorFactoryRegistry } from '../../../common/editor import { BrowserEditor } from './browserEditor.js'; import { BrowserEditorInput, BrowserEditorSerializer } from '../common/browserEditorInput.js'; import { BrowserViewUri } from '../../../../platform/browserView/common/browserViewUri.js'; -import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js'; import { IEditorResolverService, RegisteredEditorPriority } from '../../../services/editor/common/editorResolverService.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; @@ -50,7 +49,7 @@ class BrowserEditorResolverContribution implements IWorkbenchContribution { constructor( @IEditorResolverService editorResolverService: IEditorResolverService, - @IInstantiationService instantiationService: IInstantiationService + @IBrowserViewWorkbenchService browserViewWorkbenchService: IBrowserViewWorkbenchService, ) { editorResolverService.registerEditor( `${Schemas.vscodeBrowser}:/**`, @@ -70,10 +69,7 @@ class BrowserEditorResolverContribution implements IWorkbenchContribution { throw new Error(`Invalid browser view resource: ${resource.toString()}`); } - const browserInput = instantiationService.createInstance(BrowserEditorInput, { - ...options?.viewState, - id: parsed.id - }); + const browserInput = browserViewWorkbenchService.getOrCreateLazy(parsed.id, options?.viewState); // Start resolving the input right away. This will create the browser view. // This allows browser views to be loaded in the background. @@ -82,8 +78,8 @@ class BrowserEditorResolverContribution implements IWorkbenchContribution { return { editor: browserInput, options: { - ...options, - pinned: !!browserInput.url // pin if navigated + pinned: !!browserInput.url, // pin if navigated + ...options } }; } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts index d189013b3adb7..4645abef4945e 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts @@ -3,15 +3,22 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { BrowserViewCommandId, IBrowserViewService, ipcBrowserViewChannelName } from '../../../../platform/browserView/common/browserView.js'; -import { IBrowserViewWorkbenchService, IBrowserViewModel, BrowserViewModel } from '../common/browserView.js'; +import { BrowserViewCommandId, BrowserViewStorageScope, IBrowserViewOpenOptions, IBrowserViewOwner, IBrowserViewService, IBrowserViewState, ipcBrowserViewChannelName } from '../../../../platform/browserView/common/browserView.js'; +import { IBrowserViewWorkbenchService, IBrowserViewModel, BrowserViewModel, IBrowserEditorViewState } 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'; +import { IWorkspaceContextService, WorkbenchState } from '../../../../platform/workspace/common/workspace.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; +import { AUX_WINDOW_GROUP, IEditorService } from '../../../services/editor/common/editorService.js'; +import { mainWindow } from '../../../../base/browser/window.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IWorkspaceTrustManagementService } from '../../../../platform/workspace/common/workspaceTrust.js'; +import { BrowserEditorInput } from '../common/browserEditorInput.js'; +import { IEditorGroupsService } from '../../../services/editor/common/editorGroupsService.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; /** Command IDs whose accelerators are shown in browser view context menus. */ const browserViewContextMenuCommands = [ @@ -24,28 +31,85 @@ export class BrowserViewWorkbenchService extends Disposable implements IBrowserV declare readonly _serviceBrand: undefined; private readonly _browserViewService: IBrowserViewService; - private readonly _models = new Map(); + private readonly _known = new Map(); + private readonly _mainWindowId: number; + + private readonly _onDidChangeBrowserViews = this._register(new Emitter()); + readonly onDidChangeBrowserViews: Event = this._onDidChangeBrowserViews.event; constructor( @IMainProcessService mainProcessService: IMainProcessService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, - @IKeybindingService private readonly keybindingService: IKeybindingService + @IKeybindingService private readonly keybindingService: IKeybindingService, + @IEditorService private readonly editorService: IEditorService, + @IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IWorkspaceTrustManagementService private readonly workspaceTrustManagementService: IWorkspaceTrustManagementService, + @ILogService private readonly logService: ILogService ) { super(); const channel = mainProcessService.getChannel(ipcBrowserViewChannelName); this._browserViewService = ProxyChannel.toService(channel); + this._mainWindowId = mainWindow.vscodeWindowId; this.sendKeybindings(); this._register(this.keybindingService.onDidUpdateKeybindings(() => this.sendKeybindings())); + + // Start asynchronously creating models for all views we already own. + void this._initializeExistingViews().catch(e => { + this.logService.error('[BrowserViewWorkbenchService] Failed to initialize existing browser views.', e); + }); + + // Listen for new browser views + this._register(this._browserViewService.onDidCreateBrowserView(e => { + if (e.info.owner.mainWindowId !== this._mainWindowId) { + return; // Not for this window + } + + // Eagerly create the model from the state we already have + this._createModel(e.info.id, e.info.owner, e.info.state); + + const editor = this._known.get(e.info.id); + if (editor) { + this._openEditorForCreatedView(editor, e.openOptions); + } + })); } - async getOrCreateBrowserViewModel(id: string): Promise { - return this._getBrowserViewModel(id, true); + getKnownBrowserViews(): Map { + return this._known; } - async getBrowserViewModel(id: string): Promise { - return this._getBrowserViewModel(id, false); + getOrCreateLazy(id: string, initialState?: IBrowserEditorViewState, model?: IBrowserViewModel): BrowserEditorInput { + if (!this._known.has(id)) { + const input = this.instantiationService.createInstance(BrowserEditorInput, { id, ...initialState }, async () => { + const state = await this._browserViewService.getOrCreateBrowserView( + id, + { + owner: this._getDefaultOwner(), + scope: await this._resolveStorageScope(), + initialState: { + url: initialState?.url, + title: initialState?.title, + lastFavicon: initialState?.favicon + } + } + ); + return this._createModel(id, this._getDefaultOwner(), state); + }); + input.onWillDispose(() => { + this._known.delete(id); + this._onDidChangeBrowserViews.fire(); + }); + if (model) { + input.model = model; + } + this._known.set(id, input); + this._onDidChangeBrowserViews.fire(); + } + + return this._known.get(id)!; } async clearGlobalStorage(): Promise { @@ -57,31 +121,91 @@ export class BrowserViewWorkbenchService extends Disposable implements IBrowserV return this._browserViewService.clearWorkspaceStorage(workspaceId); } - private async _getBrowserViewModel(id: string, create: boolean): Promise { - let model = this._models.get(id); - if (model) { - return model; - } + private _getDefaultOwner(): IBrowserViewOwner { + return { mainWindowId: this._mainWindowId }; + } + + private async _resolveStorageScope(): Promise { + const dataStorageSetting = this.configurationService.getValue( + 'workbench.browser.dataStorage' + ) ?? BrowserViewStorageScope.Global; - model = this.instantiationService.createInstance(BrowserViewModel, id, this._browserViewService); - this._models.set(id, model); + await this.workspaceTrustManagementService.workspaceTrustInitialized; - // Initialize the model with current state - try { - await model.initialize(create); - } catch (e) { - this._models.delete(id); - throw e; + const isWorkspaceUntrusted = + this.workspaceContextService.getWorkbenchState() !== WorkbenchState.EMPTY && + !this.workspaceTrustManagementService.isWorkspaceTrusted(); + + return isWorkspaceUntrusted ? BrowserViewStorageScope.Ephemeral : dataStorageSetting; + } + + /** + * Fetch all views owned by this window from the main service and create + * models for them so they are available synchronously. + */ + private async _initializeExistingViews(): Promise { + const views = await this._browserViewService.getBrowserViews(this._mainWindowId); + for (const info of views) { + if (!this._known.has(info.id)) { + this._createModel(info.id, info.owner, info.state); + } } + } - // Clean up model when disposed - Event.once(model.onWillDispose)(() => { - this._models.delete(id); - }); + private _createModel(id: string, owner: IBrowserViewOwner, state: IBrowserViewState): IBrowserViewModel { + // Don't double-create + const existing = this._known.get(id)?.model; + if (existing) { + return existing; + } + + const model = this.instantiationService.createInstance(BrowserViewModel, id, owner, state, this._browserViewService); + + // Sanity: both pass and assign the model to be sure. It will no-op if already set. + this.getOrCreateLazy(id, {}, model).model = model; return model; } + /** + * Open an editor tab for a newly created browser view. + */ + private _openEditorForCreatedView(view: BrowserEditorInput, openOptions: IBrowserViewOpenOptions): void { + const opts = openOptions; + + // Resolve target group: auxiliary window, parent's group, or default + let targetGroup: number | typeof AUX_WINDOW_GROUP | undefined; + if (opts.auxiliaryWindow) { + targetGroup = AUX_WINDOW_GROUP; + } else if (opts.parentViewId) { + targetGroup = this._findEditorGroupForView(opts.parentViewId); + } + + void this.editorService.openEditor(view, { + inactive: opts.background, + preserveFocus: opts.preserveFocus, + pinned: opts.pinned, + auxiliary: opts.auxiliaryWindow + ? { bounds: opts.auxiliaryWindow, compact: true } + : undefined, + }, targetGroup); + } + + /** + * Find the editor group that currently contains a browser view with the + * given ID, or undefined if not open in any group. + */ + private _findEditorGroupForView(viewId: string): number | undefined { + for (const group of this.editorGroupsService.groups) { + for (const editor of group.editors) { + if (editor instanceof BrowserEditorInput && editor.id === viewId) { + return group.id; + } + } + } + return undefined; + } + private sendKeybindings(): void { const keybindings: { [commandId: string]: string } = Object.create(null); for (const commandId of browserViewContextMenuCommands) { diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts index 2f0c1db0f6c0c..e51217a53b5a6 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts @@ -25,6 +25,7 @@ import { ITelemetryService } from '../../../../../platform/telemetry/common/tele import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js'; import { BrowserViewCommandId } from '../../../../../platform/browserView/common/browserView.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../common/contributions.js'; +import { IBrowserViewWorkbenchService } from '../../common/browserView.js'; import { IConfigurationRegistry, Extensions as ConfigurationExtensions } from '../../../../../platform/configuration/common/configurationRegistry.js'; import { workbenchConfigurationNodeBase } from '../../../../common/configuration.js'; import { IExternalOpener, IOpenerService } from '../../../../../platform/opener/common/opener.js'; @@ -279,6 +280,7 @@ class OpenIntegratedBrowserAction extends Action2 { async run(accessor: ServicesAccessor, urlOrOptions?: string | IOpenBrowserOptions): Promise { const editorService = accessor.get(IEditorService); const telemetryService = accessor.get(ITelemetryService); + const browserViewService = accessor.get(IBrowserViewWorkbenchService); // Parse arguments const options = typeof urlOrOptions === 'string' ? { url: urlOrOptions } : (urlOrOptions ?? {}); @@ -287,11 +289,7 @@ class OpenIntegratedBrowserAction extends Action2 { if (options.reuseUrlFilter) { const filterUri = URI.parse(options.reuseUrlFilter); - const matchingEditor = editorService.editors.find((e): e is BrowserEditorInput => { - if (!(e instanceof BrowserEditorInput)) { - return false; - } - + const matchingEditor = [...browserViewService.getKnownBrowserViews().values()].find((e) => { const editorUri = URI.parse(e.url || ''); // URIs default to putting "file" scheme. Check that the scheme is really in the filter. if (filterUri.scheme && options.reuseUrlFilter!.startsWith(`${filterUri.scheme}:`) && filterUri.scheme !== editorUri.scheme) { @@ -428,10 +426,10 @@ class OpenBrowserFromViewMenuAction extends Action2 { } async run(accessor: ServicesAccessor): Promise { - const editorService = accessor.get(IEditorService); + const browserViewService = accessor.get(IBrowserViewWorkbenchService); const commandService = accessor.get(ICommandService); - const hasOpenBrowserEditor = editorService.editors.some(editor => editor instanceof BrowserEditorInput); + const hasOpenBrowserEditor = browserViewService.getKnownBrowserViews().size > 0; if (hasOpenBrowserEditor) { await commandService.executeCommand(BrowserViewCommandId.QuickOpen); @@ -477,25 +475,15 @@ class BrowserEditorOpenContextKeyContribution extends Disposable implements IWor constructor( @IContextKeyService contextKeyService: IContextKeyService, - @IEditorService editorService: IEditorService, + @IBrowserViewWorkbenchService browserViewService: IBrowserViewWorkbenchService, ) { super(); const contextKey = CONTEXT_BROWSER_EDITOR_OPEN.bindTo(contextKeyService); - const update = () => contextKey.set(editorService.editors.some(e => e instanceof BrowserEditorInput)); + const update = () => contextKey.set(browserViewService.getKnownBrowserViews().size > 0); update(); - - this._register(editorService.onWillOpenEditor(e => { - if (e.editor instanceof BrowserEditorInput) { - contextKey.set(true); - } - })); - this._register(editorService.onDidCloseEditor(e => { - if (e.editor instanceof BrowserEditorInput) { - update(); - } - })); + this._register(browserViewService.onDidChangeBrowserViews(() => update())); } } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts index 44a84be163943..641cd8bb0dc37 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts @@ -12,6 +12,7 @@ import { IAgentNetworkFilterService } from '../../../../../platform/networkFilte import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { IToolResult } from '../../../chat/common/tools/languageModelToolsService.js'; import { BrowserEditorInput } from '../../common/browserEditorInput.js'; +import { IBrowserViewWorkbenchService } from '../../common/browserView.js'; // eslint-disable-next-line local/code-import-patterns import type { Page } from 'playwright-core'; @@ -145,10 +146,10 @@ export function errorResult(message: string): IToolResult { * exists. When {@link playwrightService} is provided, only pages tracked by Playwright * (i.e. shared with the agent) are considered. * - * @returns The first matching {@link BrowserEditorInput}, or `undefined` if none was found. + * @returns All matching {@link BrowserEditorInput}s. */ async function findExistingPagesByHost( - editorService: IEditorService, + browserViewService: IBrowserViewWorkbenchService, playwrightService: IPlaywrightService | undefined, url: string, ): Promise { @@ -162,7 +163,7 @@ async function findExistingPagesByHost( : undefined; const results: BrowserEditorInput[] = []; - for (const editor of editorService.editors) { + for (const editor of browserViewService.getKnownBrowserViews().values()) { if (!(editor instanceof BrowserEditorInput)) { continue; } @@ -197,11 +198,12 @@ async function findExistingPagesByHost( */ export async function getExistingPagesResult( editorService: IEditorService, + browserViewService: IBrowserViewWorkbenchService, playwrightService: IPlaywrightService | undefined, url: string, formatOptions?: FormatBrowserEditorLinesOptions ): Promise { - const existing = await findExistingPagesByHost(editorService, playwrightService, url); + const existing = await findExistingPagesByHost(browserViewService, playwrightService, url); if (existing.length === 0) { return undefined; } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/browserTools.contribution.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/browserTools.contribution.ts index b1097a0d1f725..4dad58cb16fff 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/browserTools.contribution.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/browserTools.contribution.ts @@ -14,7 +14,7 @@ import { registerWorkbenchContribution2, WorkbenchPhase, type IWorkbenchContribu import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { IChatContextService } from '../../../chat/browser/contextContrib/chatContextService.js'; import { ILanguageModelToolsService, ToolDataSource, ToolSet } from '../../../chat/common/tools/languageModelToolsService.js'; -import { BrowserEditorInput } from '../../common/browserEditorInput.js'; +import { IBrowserViewWorkbenchService } from '../../common/browserView.js'; import { formatBrowserEditorList } from './browserToolHelpers.js'; import { ClickBrowserTool, ClickBrowserToolData } from './clickBrowserTool.js'; import { DragElementTool, DragElementToolData } from './dragElementTool.js'; @@ -45,6 +45,7 @@ class BrowserChatAgentToolsContribution extends Disposable implements IWorkbench @IPlaywrightService private readonly playwrightService: IPlaywrightService, @IChatContextService private readonly chatContextService: IChatContextService, @IEditorService private readonly editorService: IEditorService, + @IBrowserViewWorkbenchService private readonly browserViewService: IBrowserViewWorkbenchService, @IAgentNetworkFilterService private readonly agentNetworkFilterService: IAgentNetworkFilterService, ) { super(); @@ -111,24 +112,20 @@ class BrowserChatAgentToolsContribution extends Disposable implements IWorkbench this._trackedIds = new Set(ids); this._updateBrowserContext(); })); - this._toolsStore.add(this.editorService.onDidEditorsChange(() => this._updateBrowserContext())); + this._toolsStore.add(this.browserViewService.onDidChangeBrowserViews(() => this._updateBrowserContext())); this._toolsStore.add(this.agentNetworkFilterService.onDidChange(() => this._updateBrowserContext())); } private _updateBrowserContext(): void { - const trackedEditors: BrowserEditorInput[] = []; - for (const editor of this.editorService.editors) { - if (editor instanceof BrowserEditorInput && this._trackedIds.has(editor.id)) { - trackedEditors.push(editor); - } - } + const trackedBrowsers = [...this.browserViewService.getKnownBrowserViews().values()] + .filter(entry => this._trackedIds.has(entry.id)); - if (trackedEditors.length === 0) { + if (trackedBrowsers.length === 0) { this.chatContextService.updateWorkspaceContextItems(BrowserChatAgentToolsContribution.CONTEXT_ID, []); return; } - const list = formatBrowserEditorList(this.editorService, trackedEditors, { agentNetworkFilterService: this.agentNetworkFilterService }); + const list = formatBrowserEditorList(this.editorService, trackedBrowsers, { agentNetworkFilterService: this.agentNetworkFilterService }); this.chatContextService.updateWorkspaceContextItems(BrowserChatAgentToolsContribution.CONTEXT_ID, [{ handle: 0, label: localize('browserContext.label', "Browser Pages"), diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts index 00fd405d9df6f..89b5d2159b023 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts @@ -13,6 +13,7 @@ import { IEditorService } from '../../../../services/editor/common/editorService import { ToolDataSource, type CountTokensCallback, type IPreparedToolInvocation, type IToolData, type IToolImpl, type IToolInvocation, type IToolInvocationPreparationContext, type IToolResult, type ToolProgress } from '../../../chat/common/tools/languageModelToolsService.js'; import { IAgentNetworkFilterService } from '../../../../../platform/networkFilter/common/networkFilterService.js'; import { createBrowserPageLink, getExistingPagesResult } from './browserToolHelpers.js'; +import { IBrowserViewWorkbenchService } from '../../common/browserView.js'; export const OpenPageToolId = 'open_browser_page'; @@ -49,6 +50,7 @@ export class OpenBrowserTool implements IToolImpl { constructor( @IPlaywrightService private readonly playwrightService: IPlaywrightService, @IEditorService private readonly editorService: IEditorService, + @IBrowserViewWorkbenchService private readonly browserViewService: IBrowserViewWorkbenchService, @IAgentNetworkFilterService private readonly agentNetworkFilterService: IAgentNetworkFilterService, ) { } @@ -84,7 +86,7 @@ export class OpenBrowserTool implements IToolImpl { const params = invocation.parameters as IOpenBrowserToolParams; if (!params.forceNew) { - const existingResult = await getExistingPagesResult(this.editorService, this.playwrightService, params.url, { agentNetworkFilterService: this.agentNetworkFilterService }); + const existingResult = await getExistingPagesResult(this.editorService, this.browserViewService, this.playwrightService, params.url, { agentNetworkFilterService: this.agentNetworkFilterService }); if (existingResult) { return existingResult; } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserToolNonAgentic.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserToolNonAgentic.ts index 12ca0b468f71e..149fdd44c796a 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserToolNonAgentic.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserToolNonAgentic.ts @@ -14,6 +14,7 @@ import { type CountTokensCallback, type IPreparedToolInvocation, type IToolData, import { IOpenBrowserToolParams, OpenBrowserToolData } from './openBrowserTool.js'; import { MarkdownString } from '../../../../../base/common/htmlContent.js'; import { createBrowserPageLink, getExistingPagesResult } from './browserToolHelpers.js'; +import { IBrowserViewWorkbenchService } from '../../common/browserView.js'; export const OpenBrowserToolNonAgenticData: IToolData = { ...OpenBrowserToolData, @@ -24,6 +25,7 @@ export class OpenBrowserToolNonAgentic implements IToolImpl { constructor( @ITelemetryService private readonly telemetryService: ITelemetryService, @IEditorService private readonly editorService: IEditorService, + @IBrowserViewWorkbenchService private readonly browserViewService: IBrowserViewWorkbenchService, ) { } async prepareToolInvocation(context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { @@ -52,7 +54,7 @@ export class OpenBrowserToolNonAgentic implements IToolImpl { const params = invocation.parameters as IOpenBrowserToolParams; if (!params.forceNew) { - const existingResult = await getExistingPagesResult(this.editorService, undefined, params.url, { excludeIds: true }); + const existingResult = await getExistingPagesResult(this.editorService, this.browserViewService, undefined, params.url, { excludeIds: true }); if (existingResult) { return existingResult; } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/screenshotBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/screenshotBrowserTool.ts index 11a1b91b22cdb..a0f6fc6925773 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/screenshotBrowserTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/screenshotBrowserTool.ts @@ -96,7 +96,11 @@ export class ScreenshotBrowserTool implements IToolImpl { // Note that we don't use Playwright's screenshot methods because they cause brief flashing on the page, // and also doesn't handle zooming well. - const browserViewModel = await this.browserViewWorkbenchService.getBrowserViewModel(params.pageId); // Throws if the given pageId doesn't exist + const browserViewModel = await this.browserViewWorkbenchService.getKnownBrowserViews().get(params.pageId)?.resolve(); + if (!browserViewModel) { + return errorResult(`No browser page found with ID ${params.pageId}`); + } + const bounds = selector && await playwrightInvokeRaw(this.playwrightService, params.pageId, async (page, selector, scrollIntoViewIfNeeded) => { const locator = page.locator(selector); if (scrollIntoViewIfNeeded) {