diff --git a/src/vs/platform/browserElements/common/browserElements.ts b/src/vs/platform/browserElements/common/browserElements.ts index abd2873d924b8..218acce24fd4c 100644 --- a/src/vs/platform/browserElements/common/browserElements.ts +++ b/src/vs/platform/browserElements/common/browserElements.ts @@ -15,12 +15,27 @@ export interface IElementData { readonly bounds: IRectangle; } -export enum BrowserType { - SimpleBrowser = 'simpleBrowser', - LiveServer = 'liveServer', +/** + * Locator for identifying a browser target/webview. + * Uses either the parent webview or browser view id to uniquely identify the target. + */ +export interface IBrowserTargetLocator { + /** + * Identifier of the parent webview hosting the target. + * + * Exactly one of {@link webviewId} or {@link browserViewId} should be provided. + * Use this when the target is rendered inside a webview. + */ + readonly webviewId?: string; + /** + * Identifier of the browser view hosting the target. + * + * Exactly one of {@link webviewId} or {@link browserViewId} should be provided. + * Use this when the target is rendered inside a browser view rather than a webview. + */ + readonly browserViewId?: string; } - export interface INativeBrowserElementsService { readonly _serviceBrand: undefined; @@ -28,7 +43,24 @@ export interface INativeBrowserElementsService { // Properties readonly windowId: number; - getElementData(rect: IRectangle, token: CancellationToken, browserType: BrowserType, cancellationId?: number): Promise; + getElementData(rect: IRectangle, token: CancellationToken, locator: IBrowserTargetLocator, cancellationId?: number): Promise; + + startDebugSession(token: CancellationToken, locator: IBrowserTargetLocator, cancelAndDetachId?: number): Promise; +} + +/** + * Extract a display name from outer HTML (e.g., "div#myId.myClass1.myClass2") + */ +export function getDisplayNameFromOuterHTML(outerHTML: string): string { + const firstElementMatch = outerHTML.match(/^<([^ >]+)([^>]*?)>/); + if (!firstElementMatch) { + throw new Error('No outer element found'); + } - startDebugSession(token: CancellationToken, browserType: BrowserType, cancelAndDetachId?: number): Promise; + const tagName = firstElementMatch[1]; + const idMatch = firstElementMatch[2].match(/\s+id\s*=\s*["']([^"']+)["']/i); + const id = idMatch ? `#${idMatch[1]}` : ''; + const classMatch = firstElementMatch[2].match(/\s+class\s*=\s*["']([^"']+)["']/i); + const className = classMatch ? `.${classMatch[1].replace(/\s+/g, '.')}` : ''; + return `${tagName}${id}${className}`; } diff --git a/src/vs/platform/browserElements/electron-main/nativeBrowserElementsMainService.ts b/src/vs/platform/browserElements/electron-main/nativeBrowserElementsMainService.ts index acdcbe060b6d8..fbf52a2a06408 100644 --- a/src/vs/platform/browserElements/electron-main/nativeBrowserElementsMainService.ts +++ b/src/vs/platform/browserElements/electron-main/nativeBrowserElementsMainService.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { BrowserType, IElementData, INativeBrowserElementsService } from '../common/browserElements.js'; +import { IElementData, INativeBrowserElementsService, IBrowserTargetLocator } from '../common/browserElements.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; import { IRectangle } from '../../window/common/window.js'; import { BrowserWindow, webContents } from 'electron'; @@ -14,6 +14,7 @@ import { IWindowsMainService } from '../../windows/electron-main/windows.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import { Disposable } from '../../../base/common/lifecycle.js'; import { AddFirstParameterToFunctions } from '../../../base/common/types.js'; +import { IBrowserViewMainService } from '../../browserView/electron-main/browserViewMainService.js'; export const INativeBrowserElementsMainService = createDecorator('browserElementsMainService'); export interface INativeBrowserElementsMainService extends AddFirstParameterToFunctions /* only methods, not events */, number | undefined /* window ID */> { } @@ -27,53 +28,47 @@ interface NodeDataResponse { export class NativeBrowserElementsMainService extends Disposable implements INativeBrowserElementsMainService { _serviceBrand: undefined; - currentLocalAddress: string | undefined; - constructor( @IWindowsMainService private readonly windowsMainService: IWindowsMainService, @IAuxiliaryWindowsMainService private readonly auxiliaryWindowsMainService: IAuxiliaryWindowsMainService, - + @IBrowserViewMainService private readonly browserViewMainService: IBrowserViewMainService ) { super(); } get windowId(): never { throw new Error('Not implemented in electron-main'); } - async findWebviewTarget(debuggers: Electron.Debugger, windowId: number, browserType: BrowserType): Promise { + /** + * Find the webview target that matches the given locator. + * Checks either webviewId or browserViewId depending on what's provided. + */ + async findWebviewTarget(debuggers: Electron.Debugger, locator: IBrowserTargetLocator): Promise { const { targetInfos } = await debuggers.sendCommand('Target.getTargets'); - let target: typeof targetInfos[number] | undefined = undefined; - const matchingTarget = targetInfos.find((targetInfo: { url: string }) => { - try { - const url = new URL(targetInfo.url); - if (browserType === BrowserType.LiveServer) { - return url.searchParams.get('id') && url.searchParams.get('extensionId') === 'ms-vscode.live-server'; - } else if (browserType === BrowserType.SimpleBrowser) { - return url.searchParams.get('parentId') === windowId.toString() && url.searchParams.get('extensionId') === 'vscode.simple-browser'; + + if (locator.webviewId) { + let extensionId = ''; + for (const targetInfo of targetInfos) { + try { + const url = new URL(targetInfo.url); + if (url.searchParams.get('id') === locator.webviewId) { + extensionId = url.searchParams.get('extensionId') || ''; + break; + } + } catch (err) { + // ignore } - return false; - } catch (err) { - return false; } - }); - - // search for webview via search parameters - if (matchingTarget) { - let resultId: string | undefined; - let url: URL | undefined; - try { - url = new URL(matchingTarget.url); - resultId = url.searchParams.get('id')!; - } catch (e) { + if (!extensionId) { return undefined; } - target = targetInfos.find((targetInfo: { url: string }) => { + // search for webview via search parameters + const target = targetInfos.find((targetInfo: { url: string }) => { try { const url = new URL(targetInfo.url); - const isLiveServer = browserType === BrowserType.LiveServer && url.searchParams.get('serverWindowId') === resultId; - const isSimpleBrowser = browserType === BrowserType.SimpleBrowser && url.searchParams.get('id') === resultId && url.searchParams.has('vscodeBrowserReqId'); + const isLiveServer = extensionId === 'ms-vscode.live-server' && url.searchParams.get('serverWindowId') === locator.webviewId; + const isSimpleBrowser = extensionId === 'vscode.simple-browser' && url.searchParams.get('id') === locator.webviewId && url.searchParams.has('vscodeBrowserReqId'); if (isLiveServer || isSimpleBrowser) { - this.currentLocalAddress = url.origin; return true; } return false; @@ -81,35 +76,30 @@ export class NativeBrowserElementsMainService extends Disposable implements INat return false; } }); - - if (target) { - return target.targetId; - } + return target?.targetId; } - // fallback: search for webview without parameters based on current origin - target = targetInfos.find((targetInfo: { url: string }) => { - try { - const url = new URL(targetInfo.url); - return (this.currentLocalAddress === url.origin); - } catch (e) { - return false; - } - }); + if (locator.browserViewId) { + const webContentsInstance = this.browserViewMainService.tryGetBrowserView(locator.browserViewId)?.webContents; + const target = targetInfos.find((targetInfo: { targetId: string; type: string }) => { + if (targetInfo.type !== 'page') { + return false; + } - if (!target) { - return undefined; + return webContents.fromDevToolsTargetId(targetInfo.targetId) === webContentsInstance; + }); + return target?.targetId; } - return target.targetId; + return undefined; } - async waitForWebviewTargets(debuggers: Electron.Debugger, windowId: number, browserType: BrowserType): Promise { + async waitForWebviewTargets(debuggers: Electron.Debugger, locator: IBrowserTargetLocator): Promise { const start = Date.now(); const timeout = 10000; while (Date.now() - start < timeout) { - const targetId = await this.findWebviewTarget(debuggers, windowId, browserType); + const targetId = await this.findWebviewTarget(debuggers, locator); if (targetId) { return targetId; } @@ -122,7 +112,7 @@ export class NativeBrowserElementsMainService extends Disposable implements INat return undefined; } - async startDebugSession(windowId: number | undefined, token: CancellationToken, browserType: BrowserType, cancelAndDetachId?: number): Promise { + async startDebugSession(windowId: number | undefined, token: CancellationToken, locator: IBrowserTargetLocator, cancelAndDetachId?: number): Promise { const window = this.windowById(windowId); if (!window?.win) { return undefined; @@ -142,7 +132,7 @@ export class NativeBrowserElementsMainService extends Disposable implements INat } try { - const matchingTargetId = await this.waitForWebviewTargets(debuggers, windowId!, browserType); + const matchingTargetId = await this.waitForWebviewTargets(debuggers, locator); if (!matchingTargetId) { if (debuggers.isAttached()) { debuggers.detach(); @@ -187,7 +177,7 @@ export class NativeBrowserElementsMainService extends Disposable implements INat } } - async getElementData(windowId: number | undefined, rect: IRectangle, token: CancellationToken, browserType: BrowserType, cancellationId?: number): Promise { + async getElementData(windowId: number | undefined, rect: IRectangle, token: CancellationToken, locator: IBrowserTargetLocator, cancellationId?: number): Promise { const window = this.windowById(windowId); if (!window?.win) { return undefined; @@ -208,7 +198,7 @@ export class NativeBrowserElementsMainService extends Disposable implements INat let targetSessionId: string | undefined = undefined; try { - const targetId = await this.findWebviewTarget(debuggers, windowId!, browserType); + const targetId = await this.findWebviewTarget(debuggers, locator); const { sessionId } = await debuggers.sendCommand('Target.attachToTarget', { targetId: targetId, flatten: true, @@ -373,7 +363,7 @@ export class NativeBrowserElementsMainService extends Disposable implements INat const content = model.content; const margin = model.margin; const x = Math.min(margin[0], content[0]); - const y = Math.min(margin[1], content[1]) + 32.4; // 32.4 is height of the title bar + const y = Math.min(margin[1], content[1]); const width = Math.max(margin[2] - margin[0], content[2] - content[0]); const height = Math.max(margin[5] - margin[1], content[5] - content[1]); @@ -416,7 +406,7 @@ export class NativeBrowserElementsMainService extends Disposable implements INat }); } - formatMatchedStyles(matched: { inlineStyle?: { cssProperties?: Array<{ name: string; value: string }> }; matchedCSSRules?: Array<{ rule: { selectorList: { selectors: Array<{ text: string }> }; origin: string; style: { cssProperties: Array<{ name: string; value: string }> } } }>; inherited?: Array<{ matchedCSSRules?: Array<{ rule: { selectorList: { selectors: Array<{ text: string }> }; origin: string; style: { cssProperties: Array<{ name: string; value: string }> } } }> }> }): string { + formatMatchedStyles(matched: { inlineStyle?: { cssProperties?: Array<{ name: string; value: string }> }; matchedCSSRules?: Array<{ rule: { selectorList: { selectors: Array<{ text: string }> }; origin: string; style: { cssProperties: Array<{ name: string; value: string }> } } }>; inherited?: Array<{ inlineStyle?: { cssText: string }; matchedCSSRules?: Array<{ rule: { selectorList: { selectors: Array<{ text: string }> }; origin: string; style: { cssProperties: Array<{ name: string; value: string }> } } }> }> }): string { const lines: string[] = []; // inline @@ -451,6 +441,14 @@ export class NativeBrowserElementsMainService extends Disposable implements INat if (matched.inherited?.length) { let level = 1; for (const inherited of matched.inherited) { + const inline = inherited.inlineStyle; + if (inline) { + lines.push(`/* Inherited from ancestor level ${level} (inline) */`); + lines.push('element {'); + lines.push(inline.cssText); + lines.push('}\n'); + } + const rules = inherited.matchedCSSRules || []; for (const ruleEntry of rules) { const rule = ruleEntry.rule; diff --git a/src/vs/platform/browserView/electron-main/browserView.ts b/src/vs/platform/browserView/electron-main/browserView.ts index 1619ad1bccbed..41d1c8e9522ba 100644 --- a/src/vs/platform/browserView/electron-main/browserView.ts +++ b/src/vs/platform/browserView/electron-main/browserView.ts @@ -271,6 +271,10 @@ export class BrowserView extends Disposable { }); } + get webContents(): Electron.WebContents { + return this._view.webContents; + } + /** * Get the current state of this browser view */ diff --git a/src/vs/platform/browserView/electron-main/browserViewMainService.ts b/src/vs/platform/browserView/electron-main/browserViewMainService.ts index 403ebc74399b8..fae1b76846f9a 100644 --- a/src/vs/platform/browserView/electron-main/browserViewMainService.ts +++ b/src/vs/platform/browserView/electron-main/browserViewMainService.ts @@ -16,7 +16,7 @@ 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 + tryGetBrowserView(id: string): BrowserView | undefined; } // Same as webviews @@ -96,6 +96,10 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa return view.getState(); } + tryGetBrowserView(id: string): BrowserView | undefined { + return this.browserViews.get(id); + } + /** * Get a browser view or throw if not found */ diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts index 590290854865a..554cc6279ada3 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts @@ -6,7 +6,7 @@ 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 { CancellationToken, CancellationTokenSource } 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'; @@ -32,13 +32,21 @@ import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.j 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 { IBrowserElementsService } from '../../../services/browserElements/browser/browserElementsService.js'; +import { IChatWidgetService } from '../../chat/browser/chat.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { Codicon } from '../../../../base/common/codicons.js'; import { encodeBase64, VSBuffer } from '../../../../base/common/buffer.js'; +import { IChatRequestVariableEntry } from '../../chat/common/attachments/chatVariableEntries.js'; +import { IBrowserTargetLocator, getDisplayNameFromOuterHTML } from '../../../../platform/browserElements/common/browserElements.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")); +export const CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE = new RawContextKey('browserElementSelectionActive', false, localize('browser.elementSelectionActive', "Whether element selection is currently active")); class BrowserNavigationBar extends Disposable { private readonly _urlInput: HTMLInputElement; @@ -152,10 +160,12 @@ export class BrowserEditor extends EditorPane { private _canGoForwardContext!: IContextKey; private _storageScopeContext!: IContextKey; private _devToolsOpenContext!: IContextKey; + private _elementSelectionActiveContext!: IContextKey; private _model: IBrowserViewModel | undefined; private readonly _inputDisposables = this._register(new DisposableStore()); private overlayManager: BrowserOverlayManager | undefined; + private _elementSelectionCts: CancellationTokenSource | undefined; constructor( group: IEditorGroup, @@ -166,7 +176,10 @@ 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 + @IEditorService private readonly editorService: IEditorService, + @IBrowserElementsService private readonly browserElementsService: IBrowserElementsService, + @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, + @IConfigurationService private readonly configurationService: IConfigurationService ) { super(BrowserEditor.ID, group, telemetryService, themeService, storageService); } @@ -183,6 +196,7 @@ export class BrowserEditor extends EditorPane { 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); + this._elementSelectionActiveContext = CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE.bindTo(contextKeyService); // Currently this is always true since it is scoped to the editor container CONTEXT_BROWSER_FOCUSED.bindTo(contextKeyService); @@ -447,6 +461,99 @@ export class BrowserEditor extends EditorPane { return this._model?.toggleDevTools(); } + /** + * Start element selection in the browser view, wait for a user selection, and add it to chat. + */ + async addElementToChat(): Promise { + // If selection is already active, cancel it + if (this._elementSelectionCts) { + this._elementSelectionCts.dispose(true); + this._elementSelectionCts = undefined; + this._elementSelectionActiveContext.set(false); + return; + } + + // Start new selection + const cts = new CancellationTokenSource(); + this._elementSelectionCts = cts; + this._elementSelectionActiveContext.set(true); + + try { + // Get the resource URI for this editor + const resourceUri = this.input?.resource; + if (!resourceUri) { + throw new Error('No resource URI found'); + } + + // Create a locator - for integrated browser, use the URI scheme to identify + // Browser view URIs have a special scheme we can match against + const locator: IBrowserTargetLocator = { browserViewId: BrowserViewUri.getId(this.input.resource) }; + + // Start debug session for integrated browser + await this.browserElementsService.startDebugSession(cts.token, locator); + + // Get the browser container bounds + const { width, height } = this._browserContainer.getBoundingClientRect(); + + // Get element data from user selection + const elementData = await this.browserElementsService.getElementData({ x: 0, y: 0, width, height }, cts.token, locator); + if (!elementData) { + throw new Error('Element data not found'); + } + + const bounds = elementData.bounds; + const toAttach: IChatRequestVariableEntry[] = []; + + // Prepare HTML/CSS context + const displayName = getDisplayNameFromOuterHTML(elementData.outerHTML); + const attachCss = this.configurationService.getValue('chat.sendElementsToChat.attachCSS'); + let value = (attachCss ? 'Attached HTML and CSS Context' : 'Attached HTML Context') + '\n\n' + elementData.outerHTML; + if (attachCss) { + value += '\n\n' + elementData.computedStyle; + } + + toAttach.push({ + id: 'element-' + Date.now(), + name: displayName, + fullName: displayName, + value: value, + kind: 'element', + icon: ThemeIcon.fromId(Codicon.layout.id), + }); + + // Attach screenshot if enabled + if (this.configurationService.getValue('chat.sendElementsToChat.attachImages') && this._model) { + const screenshotBuffer = await this._model.captureScreenshot({ + quality: 90, + rect: bounds + }); + + toAttach.push({ + id: 'element-screenshot-' + Date.now(), + name: 'Element Screenshot', + fullName: 'Element Screenshot', + kind: 'image', + value: screenshotBuffer.buffer + }); + } + + // Attach to chat widget + const widget = await this.chatWidgetService.revealWidget() ?? this.chatWidgetService.lastFocusedWidget; + widget?.attachmentModel?.addContext(...toAttach); + + } catch (error) { + if (!cts.token.isCancellationRequested) { + this.logService.error('BrowserEditor.addElementToChat: Failed to select element', error); + } + } finally { + cts.dispose(); + if (this._elementSelectionCts === cts) { + this._elementSelectionCts = undefined; + this._elementSelectionActiveContext.set(false); + } + } + } + /** * Update navigation state and context keys */ @@ -527,6 +634,12 @@ export class BrowserEditor extends EditorPane { override clearInput(): void { this._inputDisposables.clear(); + // Cancel any active element selection + if (this._elementSelectionCts) { + this._elementSelectionCts.dispose(true); + this._elementSelectionCts = undefined; + } + void this._model?.setVisible(false); this._model = undefined; @@ -534,6 +647,7 @@ export class BrowserEditor extends EditorPane { this._canGoForwardContext.reset(); this._storageScopeContext.reset(); this._devToolsOpenContext.reset(); + this._elementSelectionActiveContext.reset(); this._navigationBar.clear(); this.setBackgroundImage(undefined); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts index b3e409c365f67..d57048492afb0 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts @@ -11,10 +11,11 @@ import { KeybindingWeight } from '../../../../platform/keybinding/common/keybind 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 { BrowserEditor, CONTEXT_BROWSER_CAN_GO_BACK, CONTEXT_BROWSER_CAN_GO_FORWARD, CONTEXT_BROWSER_DEVTOOLS_OPEN, CONTEXT_BROWSER_FOCUSED, CONTEXT_BROWSER_STORAGE_SCOPE, CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE } 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'; +import { ChatContextKeys } from '../../chat/common/actions/chatContextKeys.js'; // Context key expression to check if browser editor is active const BROWSER_EDITOR_ACTIVE = ContextKeyExpr.equals('activeEditor', BrowserEditor.ID); @@ -121,7 +122,6 @@ class ReloadAction extends Action2 { 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 @@ -139,6 +139,33 @@ class ReloadAction extends Action2 { } } +class AddElementToChatAction extends Action2 { + static readonly ID = 'workbench.action.browser.addElementToChat'; + + constructor() { + super({ + id: AddElementToChatAction.ID, + title: localize2('browser.addElementToChatAction', 'Add Element to Chat'), + icon: Codicon.inspect, + f1: true, + precondition: ChatContextKeys.enabled, + toggled: CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE, + menu: { + id: MenuId.BrowserActionsToolbar, + group: 'actions', + order: 1, + when: ChatContextKeys.enabled + } + }); + } + + async run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { + if (browserEditor instanceof BrowserEditor) { + await browserEditor.addElementToChat(); + } + } +} + class ToggleDevToolsAction extends Action2 { static readonly ID = 'workbench.action.browser.toggleDevTools'; @@ -153,10 +180,9 @@ class ToggleDevToolsAction extends Action2 { menu: { id: MenuId.BrowserActionsToolbar, group: 'actions', - order: 1, + order: 2, when: BROWSER_EDITOR_ACTIVE - }, - precondition: BROWSER_EDITOR_ACTIVE + } }); } @@ -222,6 +248,7 @@ registerAction2(OpenIntegratedBrowserAction); registerAction2(GoBackAction); registerAction2(GoForwardAction); registerAction2(ReloadAction); +registerAction2(AddElementToChatAction); registerAction2(ToggleDevToolsAction); registerAction2(ClearGlobalBrowserStorageAction); registerAction2(ClearWorkspaceBrowserStorageAction); diff --git a/src/vs/workbench/contrib/chat/browser/attachments/simpleBrowserEditorOverlay.ts b/src/vs/workbench/contrib/chat/browser/attachments/simpleBrowserEditorOverlay.ts index ddd15d9dd8e3d..4ebc648fbfb36 100644 --- a/src/vs/workbench/contrib/chat/browser/attachments/simpleBrowserEditorOverlay.ts +++ b/src/vs/workbench/contrib/chat/browser/attachments/simpleBrowserEditorOverlay.ts @@ -16,8 +16,7 @@ import { EditorGroupView } from '../../../../browser/parts/editor/editorGroupVie import { Event } from '../../../../../base/common/event.js'; import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; -import { EditorResourceAccessor, SideBySideEditor } from '../../../../common/editor.js'; -import { isEqual, joinPath } from '../../../../../base/common/resources.js'; +import { joinPath } from '../../../../../base/common/resources.js'; import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { IHostService } from '../../../../services/host/browser/host.js'; import { IChatWidgetService } from '../chat.js'; @@ -35,7 +34,8 @@ import { IPreferencesService } from '../../../../services/preferences/common/pre import { IBrowserElementsService } from '../../../../services/browserElements/browser/browserElementsService.js'; import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; import { IAction, toAction } from '../../../../../base/common/actions.js'; -import { BrowserType } from '../../../../../platform/browserElements/common/browserElements.js'; +import { WebviewInput } from '../../../webviewPanel/browser/webviewEditorInput.js'; +import { IBrowserTargetLocator, getDisplayNameFromOuterHTML } from '../../../../../platform/browserElements/common/browserElements.js'; class SimpleBrowserOverlayWidget { @@ -47,7 +47,7 @@ class SimpleBrowserOverlayWidget { private _timeout: Timeout | undefined = undefined; - private _activeBrowserType: BrowserType | undefined = undefined; + private _activeLocator: IBrowserTargetLocator | undefined = undefined; constructor( private readonly _editor: IEditorGroup, @@ -226,8 +226,8 @@ class SimpleBrowserOverlayWidget { })); } - setActiveBrowserType(type: BrowserType | undefined) { - this._activeBrowserType = type; + setActiveLocator(locator: IBrowserTargetLocator | undefined) { + this._activeLocator = locator; } hideElement(element: HTMLElement) { @@ -249,7 +249,12 @@ class SimpleBrowserOverlayWidget { const editorContainer = this._container.querySelector('.editor-container') as HTMLDivElement; const editorContainerPosition = editorContainer ? editorContainer.getBoundingClientRect() : this._container.getBoundingClientRect(); - const elementData = await this._browserElementsService.getElementData(editorContainerPosition, cts.token, this._activeBrowserType); + const elementData = await this._browserElementsService.getElementData({ + x: editorContainerPosition.x, + y: editorContainerPosition.y + 32.4, // Height of the title bar + width: editorContainerPosition.width, + height: editorContainerPosition.height - 32.4, + }, cts.token, this._activeLocator); if (!elementData) { throw new Error('Element data not found'); } @@ -257,14 +262,16 @@ class SimpleBrowserOverlayWidget { const toAttach: IChatRequestVariableEntry[] = []; const widget = await this._chatWidgetService.revealWidget() ?? this._chatWidgetService.lastFocusedWidget; - let value = 'Attached HTML and CSS Context\n\n' + elementData.outerHTML; - if (this.configurationService.getValue('chat.sendElementsToChat.attachCSS')) { + const attachCss = this.configurationService.getValue('chat.sendElementsToChat.attachCSS'); + let value = (attachCss ? 'Attached HTML and CSS Context' : 'Attached HTML Context') + '\n\n' + elementData.outerHTML; + if (attachCss) { value += '\n\n' + elementData.computedStyle; } + const displayName = getDisplayNameFromOuterHTML(elementData.outerHTML); toAttach.push({ id: 'element-' + Date.now(), - name: this.getDisplayNameFromOuterHTML(elementData.outerHTML), - fullName: this.getDisplayNameFromOuterHTML(elementData.outerHTML), + name: displayName, + fullName: displayName, value: value, kind: 'element', icon: ThemeIcon.fromId(Codicon.layout.id), @@ -297,21 +304,6 @@ class SimpleBrowserOverlayWidget { widget?.attachmentModel?.addContext(...toAttach); } - - getDisplayNameFromOuterHTML(outerHTML: string): string { - const firstElementMatch = outerHTML.match(/^<(\w+)([^>]*?)>/); - if (!firstElementMatch) { - throw new Error('No outer element found'); - } - - const tagName = firstElementMatch[1]; - const idMatch = firstElementMatch[2].match(/\s+id\s*=\s*["']([^"']+)["']/i); - const id = idMatch ? `#${idMatch[1]}` : ''; - const classMatch = firstElementMatch[2].match(/\s+class\s*=\s*["']([^"']+)["']/i); - const className = classMatch ? `.${classMatch[1].replace(/\s+/g, '.')}` : ''; - return `${tagName}${id}${className}`; - } - dispose() { this._showStore.dispose(); } @@ -354,15 +346,10 @@ class SimpleBrowserOverlayController { connectingWebviewElement.className = 'connecting-webview-element'; - const getActiveBrowserType = () => { - const editor = group.activeEditorPane; - const isSimpleBrowser = editor?.input.editorId === 'mainThreadWebview-simpleBrowser.view'; - const isLiveServer = editor?.input.editorId === 'mainThreadWebview-browserPreview'; - return isSimpleBrowser ? BrowserType.SimpleBrowser : isLiveServer ? BrowserType.LiveServer : undefined; - }; - let cts = new CancellationTokenSource(); - const show = async () => { + const show = async (locator: IBrowserTargetLocator) => { + widget.setActiveLocator(locator); + // Show the connecting indicator while establishing the session connectingWebviewElement.textContent = localize('connectingWebviewElement', 'Connecting to webview...'); if (!container.contains(connectingWebviewElement)) { @@ -370,14 +357,11 @@ class SimpleBrowserOverlayController { } cts = new CancellationTokenSource(); - const activeBrowserType = getActiveBrowserType(); - if (activeBrowserType) { - try { - await this._browserElementsService.startDebugSession(cts.token, activeBrowserType); - } catch (error) { - connectingWebviewElement.textContent = localize('reopenErrorWebviewElement', 'Please reopen the preview.'); - return; - } + try { + await this._browserElementsService.startDebugSession(cts.token, locator); + } catch (error) { + connectingWebviewElement.textContent = localize('reopenErrorWebviewElement', 'Please reopen the preview.'); + return; } if (!container.contains(this._domNode)) { @@ -387,6 +371,7 @@ class SimpleBrowserOverlayController { }; const hide = () => { + widget.setActiveLocator(undefined); if (container.contains(this._domNode)) { cts.cancel(); this._domNode.remove(); @@ -396,32 +381,32 @@ class SimpleBrowserOverlayController { const activeEditorSignal = observableSignalFromEvent(this, Event.any(group.onDidActiveEditorChange, group.onDidModelChange)); - const activeUriObs = derivedOpts({ equalsFn: isEqual }, r => { + const activeIdObs = derivedOpts({}, r => { activeEditorSignal.read(r); // signal const editor = group.activeEditorPane; - const activeBrowser = getActiveBrowserType(); - widget.setActiveBrowserType(activeBrowser); + const isSimpleBrowser = editor?.input.editorId === 'mainThreadWebview-simpleBrowser.view'; + const isLiveServer = editor?.input.editorId === 'mainThreadWebview-browserPreview'; - if (activeBrowser) { - const uri = EditorResourceAccessor.getOriginalUri(editor?.input, { supportSideBySide: SideBySideEditor.PRIMARY }); - return uri; + if (isSimpleBrowser || isLiveServer) { + const webviewInput = editor.input as WebviewInput; + return webviewInput.webview.container.id; } return undefined; }); this._store.add(autorun(r => { - const data = activeUriObs.read(r); + const webviewId = activeIdObs.read(r); - if (!data) { + if (!webviewId) { hide(); return; } - show(); + show({ webviewId }); })); } diff --git a/src/vs/workbench/services/browserElements/browser/browserElementsService.ts b/src/vs/workbench/services/browserElements/browser/browserElementsService.ts index 4053357623cdb..7e7ae683bff5d 100644 --- a/src/vs/workbench/services/browserElements/browser/browserElementsService.ts +++ b/src/vs/workbench/services/browserElements/browser/browserElementsService.ts @@ -5,7 +5,7 @@ import { CancellationToken } from '../../../../base/common/cancellation.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; -import { BrowserType, IElementData } from '../../../../platform/browserElements/common/browserElements.js'; +import { IElementData, IBrowserTargetLocator } from '../../../../platform/browserElements/common/browserElements.js'; import { IRectangle } from '../../../../platform/window/common/window.js'; export const IBrowserElementsService = createDecorator('browserElementsService'); @@ -14,7 +14,7 @@ export interface IBrowserElementsService { _serviceBrand: undefined; // no browser implementation yet - getElementData(rect: IRectangle, token: CancellationToken, browserType: BrowserType | undefined): Promise; + getElementData(rect: IRectangle, token: CancellationToken, locator: IBrowserTargetLocator | undefined): Promise; - startDebugSession(token: CancellationToken, browserType: BrowserType): Promise; + startDebugSession(token: CancellationToken, locator: IBrowserTargetLocator): Promise; } diff --git a/src/vs/workbench/services/browserElements/browser/webBrowserElementsService.ts b/src/vs/workbench/services/browserElements/browser/webBrowserElementsService.ts index 7123a7f9b1c0c..337987925cb69 100644 --- a/src/vs/workbench/services/browserElements/browser/webBrowserElementsService.ts +++ b/src/vs/workbench/services/browserElements/browser/webBrowserElementsService.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { BrowserType, IElementData } from '../../../../platform/browserElements/common/browserElements.js'; +import { IElementData, IBrowserTargetLocator } from '../../../../platform/browserElements/common/browserElements.js'; import { IRectangle } from '../../../../platform/window/common/window.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js'; @@ -14,11 +14,11 @@ class WebBrowserElementsService implements IBrowserElementsService { constructor() { } - async getElementData(rect: IRectangle, token: CancellationToken): Promise { + async getElementData(rect: IRectangle, token: CancellationToken, locator: IBrowserTargetLocator | undefined): Promise { throw new Error('Not implemented'); } - startDebugSession(token: CancellationToken, browserType: BrowserType): Promise { + async startDebugSession(token: CancellationToken, locator: IBrowserTargetLocator): Promise { throw new Error('Not implemented'); } } diff --git a/src/vs/workbench/services/browserElements/electron-browser/browserElementsService.ts b/src/vs/workbench/services/browserElements/electron-browser/browserElementsService.ts index b2aae31f50049..021dad4e4c979 100644 --- a/src/vs/workbench/services/browserElements/electron-browser/browserElementsService.ts +++ b/src/vs/workbench/services/browserElements/electron-browser/browserElementsService.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { BrowserType, IElementData, INativeBrowserElementsService } from '../../../../platform/browserElements/common/browserElements.js'; +import { IElementData, INativeBrowserElementsService, IBrowserTargetLocator } from '../../../../platform/browserElements/common/browserElements.js'; import { IRectangle } from '../../../../platform/window/common/window.js'; import { ipcRenderer } from '../../../../base/parts/sandbox/electron-browser/globals.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; @@ -33,7 +33,7 @@ class WorkbenchBrowserElementsService implements IBrowserElementsService { @INativeBrowserElementsService private readonly simpleBrowser: INativeBrowserElementsService ) { } - async startDebugSession(token: CancellationToken, browserType: BrowserType): Promise { + async startDebugSession(token: CancellationToken, locator: IBrowserTargetLocator): Promise { const cancelAndDetachId = cancelAndDetachIdPool++; const onCancelChannel = `vscode:cancelCurrentSession${cancelAndDetachId}`; @@ -42,15 +42,15 @@ class WorkbenchBrowserElementsService implements IBrowserElementsService { disposable.dispose(); }); try { - await this.simpleBrowser.startDebugSession(token, browserType, cancelAndDetachId); + await this.simpleBrowser.startDebugSession(token, locator, cancelAndDetachId); } catch (error) { disposable.dispose(); throw new Error('No debug session target found', error); } } - async getElementData(rect: IRectangle, token: CancellationToken, browserType: BrowserType | undefined): Promise { - if (!browserType) { + async getElementData(rect: IRectangle, token: CancellationToken, locator: IBrowserTargetLocator | undefined): Promise { + if (!locator) { return undefined; } const cancelSelectionId = cancelSelectionIdPool++; @@ -59,7 +59,7 @@ class WorkbenchBrowserElementsService implements IBrowserElementsService { ipcRenderer.send(onCancelChannel, cancelSelectionId); }); try { - const elementData = await this.simpleBrowser.getElementData(rect, token, browserType, cancelSelectionId); + const elementData = await this.simpleBrowser.getElementData(rect, token, locator, cancelSelectionId); return elementData; } catch (error) { disposable.dispose();