From e6b80cc40dd580ae65d11d1d8ca9da9e5a306e5e Mon Sep 17 00:00:00 2001 From: Roo Code Date: Sun, 20 Jul 2025 05:37:14 +0000 Subject: [PATCH 1/3] feat: add integrated web preview panel with element selection - Implement WebPreviewProvider for managing preview panel - Add element selection overlay with DOM inspection - Extract comprehensive element context (HTML, CSS, position, etc.) - Integrate selected element context with AI chat - Add responsive controls and device simulation - Support multiple device viewports (mobile, tablet, desktop) - Handle cross-origin restrictions gracefully - Add comprehensive tests for WebPreviewProvider - Update documentation with usage guide Closes #5971 --- README.md | 1 + docs/web-preview.md | 123 ++++++ packages/types/src/vscode.ts | 1 + src/__tests__/WebPreviewProvider.spec.ts | 285 +++++++++++++ src/activate/registerCommands.ts | 4 + src/core/webview/WebPreviewProvider.ts | 234 +++++++++++ src/core/webview/preview/preview.css | 218 ++++++++++ src/core/webview/preview/preview.js | 440 ++++++++++++++++++++ src/eslint.config.mjs | 6 + src/extension.ts | 11 + src/package.json | 12 + src/shared/ExtensionMessage.ts | 2 + webview-ui/src/components/chat/ChatView.tsx | 7 + 13 files changed, 1344 insertions(+) create mode 100644 docs/web-preview.md create mode 100644 src/__tests__/WebPreviewProvider.spec.ts create mode 100644 src/core/webview/WebPreviewProvider.ts create mode 100644 src/core/webview/preview/preview.css create mode 100644 src/core/webview/preview/preview.js diff --git a/README.md b/README.md index e94f0d884a1..32fd549fa70 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,7 @@ Roo Code comes with powerful [tools](https://docs.roocode.com/basic-usage/how-to - Read and write files in your project - Execute commands in your VS Code terminal - Control a web browser +- **Preview web applications** and select UI elements for AI context - Use external tools via [MCP (Model Context Protocol)](https://docs.roocode.com/advanced-usage/mcp) MCP extends Roo Code's capabilities by allowing you to add unlimited custom tools. Integrate with external APIs, connect to databases, or create specialized development tools - MCP provides the framework to expand Roo Code's functionality to meet your specific needs. diff --git a/docs/web-preview.md b/docs/web-preview.md new file mode 100644 index 00000000000..d2d64e88b40 --- /dev/null +++ b/docs/web-preview.md @@ -0,0 +1,123 @@ +# Web Preview Feature + +The Web Preview feature in Roo Code allows developers to preview web applications directly within VSCode and select UI elements to provide context to the AI assistant. This feature is similar to Windsurf IDE's preview functionality. + +## Features + +- **Integrated Web Preview**: View web applications in a dedicated panel within VSCode +- **Element Selection**: Click on UI elements to select them and extract their context +- **Responsive Design Testing**: Switch between different device viewports +- **Element Context Extraction**: Automatically extract HTML, CSS, position, and other metadata +- **AI Integration**: Selected element context is automatically sent to the AI assistant + +## How to Use + +### Opening the Web Preview + +1. Open the Web Preview panel by: + - Using the command palette: `Cmd/Ctrl + Shift + P` → "Roo Code: Open Web Preview" + - Clicking on the Web Preview icon in the Roo Code sidebar + +### Loading a URL + +1. Enter the URL in the address bar at the top of the preview panel +2. Click "Go" or press Enter to load the page + +### Selecting Elements + +1. Click the "🎯 Select Element" button to enable element selection mode +2. Hover over elements in the preview to see them highlighted +3. Click on an element to select it +4. The element's context will be automatically sent to the AI chat + +### Device Simulation + +Use the device selector dropdown to switch between different viewport sizes: + +- Responsive (default) +- iPhone SE (375x667) +- iPhone 12/13 (390x844) +- iPad (768x1024) +- Desktop (1280x800) +- Full HD (1920x1080) + +## Element Context + +When you select an element, the following information is extracted and sent to the AI: + +- **HTML**: The complete HTML of the selected element +- **CSS**: All CSS rules that apply to the element +- **Position**: X, Y coordinates and dimensions (width, height) +- **Computed Styles**: Key style properties like display, position, colors, fonts +- **Attributes**: All HTML attributes on the element +- **Selectors**: Both CSS selector and XPath for the element + +## Example Use Cases + +1. **UI Debugging**: Select a misaligned element and ask the AI to fix the CSS +2. **Component Analysis**: Select a component and ask the AI to explain how it works +3. **Style Improvements**: Select an element and ask for design suggestions +4. **Accessibility**: Select elements and ask for accessibility improvements +5. **Code Generation**: Select a UI pattern and ask the AI to create similar components + +## Limitations + +- **Cross-Origin Restrictions**: Element inspection may not work on pages with strict CORS policies +- **IFrame Content**: Cannot inspect elements inside cross-origin iframes +- **Dynamic Content**: Some dynamically loaded content may not be immediately selectable + +## Technical Details + +The Web Preview feature consists of: + +1. **WebPreviewProvider**: Main provider class that manages the preview panel +2. **Preview UI**: HTML/CSS/JS for the preview interface and controls +3. **Element Inspector**: JavaScript injection for element selection and context extraction +4. **Message Passing**: Communication between the preview, extension, and AI chat + +## API Reference + +### WebPreviewProvider + +```typescript +class WebPreviewProvider { + // Load a URL in the preview + loadUrl(url: string): Promise + + // Set viewport dimensions + setViewport(width: number, height: number): Promise + + // Get the last selected element context + getSelectedElementContext(): ElementContext | undefined +} +``` + +### ElementContext Interface + +```typescript +interface ElementContext { + html: string + css: string + position: { + x: number + y: number + width: number + height: number + } + computedStyles?: Record + attributes?: Record + xpath?: string + selector?: string +} +``` + +## Contributing + +To contribute to the Web Preview feature: + +1. The main code is in `src/core/webview/WebPreviewProvider.ts` +2. Preview UI assets are in `src/core/webview/preview/` +3. Tests are in `src/__tests__/WebPreviewProvider.spec.ts` +4. Message types are defined in `src/shared/ExtensionMessage.ts` + +Please ensure all tests pass and add new tests for any new functionality. diff --git a/packages/types/src/vscode.ts b/packages/types/src/vscode.ts index 00f6bbbcba9..8b49053369a 100644 --- a/packages/types/src/vscode.ts +++ b/packages/types/src/vscode.ts @@ -53,6 +53,7 @@ export const commandIds = [ "focusInput", "acceptInput", "focusPanel", + "openWebPreview", ] as const export type CommandId = (typeof commandIds)[number] diff --git a/src/__tests__/WebPreviewProvider.spec.ts b/src/__tests__/WebPreviewProvider.spec.ts new file mode 100644 index 00000000000..e31b7121e7c --- /dev/null +++ b/src/__tests__/WebPreviewProvider.spec.ts @@ -0,0 +1,285 @@ +import * as vscode from "vscode" +import { WebPreviewProvider, ElementContext, WebPreviewMessage } from "../core/webview/WebPreviewProvider" +import { ClineProvider } from "../core/webview/ClineProvider" +import { ExtensionMessage } from "../shared/ExtensionMessage" + +// Mock dependencies +vitest.mock("vscode") +vitest.mock("../core/webview/getNonce", () => ({ + getNonce: vitest.fn().mockReturnValue("test-nonce"), +})) +vitest.mock("../core/webview/getUri", () => ({ + getUri: vitest.fn().mockReturnValue("test-uri"), +})) + +describe("WebPreviewProvider", () => { + let mockContext: vscode.ExtensionContext + let mockOutputChannel: vscode.OutputChannel + let mockWebview: vscode.Webview + let mockWebviewView: vscode.WebviewView + let mockClineProvider: ClineProvider + let provider: WebPreviewProvider + + beforeEach(() => { + vitest.clearAllMocks() + + // Mock output channel + mockOutputChannel = { + appendLine: vitest.fn(), + append: vitest.fn(), + clear: vitest.fn(), + show: vitest.fn(), + hide: vitest.fn(), + dispose: vitest.fn(), + } as unknown as vscode.OutputChannel + + // Mock extension context + mockContext = { + extensionUri: { fsPath: "/mock/extension" }, + subscriptions: [], + } as unknown as vscode.ExtensionContext + + // Mock webview + mockWebview = { + options: {}, + html: "", + postMessage: vitest.fn().mockResolvedValue(undefined), + onDidReceiveMessage: vitest.fn().mockReturnValue({ dispose: vitest.fn() }), + cspSource: "test-csp-source", + } as unknown as vscode.Webview + + // Mock webview view + mockWebviewView = { + webview: mockWebview, + onDidDispose: vitest.fn().mockReturnValue({ dispose: vitest.fn() }), + } as unknown as vscode.WebviewView + + // Mock ClineProvider + mockClineProvider = { + postMessageToWebview: vitest.fn().mockResolvedValue(undefined), + } as unknown as ClineProvider + + // Create provider instance + provider = new WebPreviewProvider(mockContext, mockOutputChannel) + }) + + describe("resolveWebviewView", () => { + it("should set up webview with correct options", async () => { + await provider.resolveWebviewView(mockWebviewView, {} as any, {} as any) + + expect(mockWebview.options).toEqual({ + enableScripts: true, + localResourceRoots: [mockContext.extensionUri], + }) + }) + + it("should set HTML content with preview controls", async () => { + await provider.resolveWebviewView(mockWebviewView, {} as any, {} as any) + + expect(mockWebview.html).toContain("urlInput") + expect(mockWebview.html).toContain("deviceSelector") + expect(mockWebview.html).toContain("toggleInspector") + expect(mockWebview.html).toContain("preview") + }) + + it("should register message handler", async () => { + await provider.resolveWebviewView(mockWebviewView, {} as any, {} as any) + + expect(mockWebview.onDidReceiveMessage).toHaveBeenCalled() + }) + }) + + describe("handleWebviewMessage", () => { + beforeEach(async () => { + provider.setClineProvider(mockClineProvider) + await provider.resolveWebviewView(mockWebviewView, {} as any, {} as any) + }) + + it("should handle elementSelected message", async () => { + const elementContext: ElementContext = { + html: "
Test
", + css: ".test { color: red; }", + position: { x: 10, y: 20, width: 100, height: 50 }, + selector: ".test", + xpath: "//div[@class='test']", + } + + const message: WebPreviewMessage = { + type: "elementSelected", + elementContext, + } + + // Get the message handler + const messageHandler = vitest.mocked(mockWebview.onDidReceiveMessage).mock.calls[0][0] + await messageHandler(message) + + // Verify ClineProvider was called with formatted message + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ + type: "webPreviewElementSelected", + text: expect.stringContaining("Selected element context"), + elementContext, + }), + ) + }) + + it("should handle urlChanged message", async () => { + const message: WebPreviewMessage = { + type: "urlChanged", + url: "https://example.com", + } + + const messageHandler = vitest.mocked(mockWebview.onDidReceiveMessage).mock.calls[0][0] + await messageHandler(message) + + // Verify URL was stored + expect(provider["currentUrl"]).toBe("https://example.com") + }) + + it("should handle error message", async () => { + const message: WebPreviewMessage = { + type: "error", + error: "Test error", + } + + const messageHandler = vitest.mocked(mockWebview.onDidReceiveMessage).mock.calls[0][0] + await messageHandler(message) + + // Verify error was logged + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith("Web Preview Error: Test error") + }) + + it("should handle previewReady message", async () => { + const message: WebPreviewMessage = { + type: "previewReady", + } + + const messageHandler = vitest.mocked(mockWebview.onDidReceiveMessage).mock.calls[0][0] + await messageHandler(message) + + // Verify ready message was logged + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith("Web preview ready") + }) + }) + + describe("formatElementContext", () => { + it("should format element context correctly", () => { + const context: ElementContext = { + html: "
Test
", + css: ".test { color: red; }", + position: { x: 10, y: 20, width: 100, height: 50 }, + selector: ".test", + xpath: "//div[@class='test']", + } + + const formatted = provider["formatElementContext"](context) + + expect(formatted).toContain("HTML:") + expect(formatted).toContain("
Test
") + expect(formatted).toContain("CSS:") + expect(formatted).toContain(".test { color: red; }") + expect(formatted).toContain("Position: 10, 20 (100x50)") + expect(formatted).toContain("CSS Selector: .test") + expect(formatted).toContain("XPath: //div[@class='test']") + }) + + it("should handle missing optional fields", () => { + const context: ElementContext = { + html: "
Test
", + css: "", + position: { x: 0, y: 0, width: 0, height: 0 }, + } + + const formatted = provider["formatElementContext"](context) + + expect(formatted).toContain("HTML:") + expect(formatted).not.toContain("CSS Selector:") + expect(formatted).not.toContain("XPath:") + }) + }) + + describe("loadUrl", () => { + it("should post loadUrl message to webview", async () => { + await provider.resolveWebviewView(mockWebviewView, {} as any, {} as any) + await provider.loadUrl("https://example.com") + + expect(mockWebview.postMessage).toHaveBeenCalledWith({ + type: "loadUrl", + url: "https://example.com", + }) + }) + + it("should not post message if view is not initialized", async () => { + await provider.loadUrl("https://example.com") + + expect(mockWebview.postMessage).not.toHaveBeenCalled() + }) + }) + + describe("setViewport", () => { + it("should post setViewport message to webview", async () => { + await provider.resolveWebviewView(mockWebviewView, {} as any, {} as any) + await provider.setViewport(1024, 768) + + expect(mockWebview.postMessage).toHaveBeenCalledWith({ + type: "setViewport", + width: 1024, + height: 768, + }) + }) + + it("should not post message if view is not initialized", async () => { + await provider.setViewport(1024, 768) + + expect(mockWebview.postMessage).not.toHaveBeenCalled() + }) + }) + + describe("getSelectedElementContext", () => { + it("should return stored element context", async () => { + const elementContext: ElementContext = { + html: "
Test
", + css: ".test { color: red; }", + position: { x: 10, y: 20, width: 100, height: 50 }, + } + + provider["selectedElementContext"] = elementContext + + expect(provider.getSelectedElementContext()).toEqual(elementContext) + }) + + it("should return undefined if no context is stored", () => { + expect(provider.getSelectedElementContext()).toBeUndefined() + }) + }) + + describe("getInstance", () => { + it("should return the singleton instance", () => { + const instance = WebPreviewProvider.getInstance() + expect(instance).toBe(provider) + }) + + it("should return undefined if no instance exists", () => { + // Create new provider without storing as singleton + const newProvider = new WebPreviewProvider(mockContext, mockOutputChannel) + newProvider.dispose() + + expect(WebPreviewProvider.getInstance()).toBeUndefined() + }) + }) + + describe("dispose", () => { + it("should clean up resources", async () => { + await provider.resolveWebviewView(mockWebviewView, {} as any, {} as any) + + const disposeSpy = vitest.fn() + provider["disposables"].push({ dispose: disposeSpy }) + + provider.dispose() + + expect(disposeSpy).toHaveBeenCalled() + expect(provider["view"]).toBeUndefined() + expect(WebPreviewProvider.getInstance()).toBeUndefined() + }) + }) +}) diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index bd925b0e900..d618b2f911c 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -218,6 +218,10 @@ const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOpt visibleProvider.postMessageToWebview({ type: "acceptInput" }) }, + openWebPreview: () => { + // Focus on the web preview panel + vscode.commands.executeCommand("roo-cline.WebPreviewProvider.focus") + }, }) export const openClineInNewTab = async ({ context, outputChannel }: Omit) => { diff --git a/src/core/webview/WebPreviewProvider.ts b/src/core/webview/WebPreviewProvider.ts new file mode 100644 index 00000000000..01c4c3eae41 --- /dev/null +++ b/src/core/webview/WebPreviewProvider.ts @@ -0,0 +1,234 @@ +import * as vscode from "vscode" +import * as path from "path" +import { getNonce } from "./getNonce" +import { getUri } from "./getUri" +import { ClineProvider } from "./ClineProvider" +import { ExtensionMessage } from "../../shared/ExtensionMessage" + +export interface ElementContext { + html: string + css: string + position: { + x: number + y: number + width: number + height: number + } + computedStyles?: Record + attributes?: Record + xpath?: string + selector?: string +} + +export interface WebPreviewMessage { + type: "elementSelected" | "previewReady" | "error" | "urlChanged" | "viewportChanged" + elementContext?: ElementContext + url?: string + viewport?: { width: number; height: number } + error?: string +} + +export class WebPreviewProvider implements vscode.WebviewViewProvider { + public static readonly viewId = "roo-code.webPreview" + private static instance?: WebPreviewProvider + + private view?: vscode.WebviewView + private disposables: vscode.Disposable[] = [] + private currentUrl?: string + private selectedElementContext?: ElementContext + private clineProvider?: ClineProvider + + constructor( + private readonly context: vscode.ExtensionContext, + private readonly outputChannel: vscode.OutputChannel, + ) { + WebPreviewProvider.instance = this + } + + public static getInstance(): WebPreviewProvider | undefined { + return WebPreviewProvider.instance + } + + public setClineProvider(provider: ClineProvider) { + this.clineProvider = provider + } + + public async resolveWebviewView( + webviewView: vscode.WebviewView, + _context: vscode.WebviewViewResolveContext, + _token: vscode.CancellationToken, + ) { + this.view = webviewView + + webviewView.webview.options = { + enableScripts: true, + localResourceRoots: [this.context.extensionUri], + } + + webviewView.webview.html = this.getHtmlContent(webviewView.webview) + + // Set up message listener + const messageDisposable = webviewView.webview.onDidReceiveMessage(async (message: WebPreviewMessage) => { + await this.handleWebviewMessage(message) + }) + this.disposables.push(messageDisposable) + + // Handle view disposal + webviewView.onDidDispose( + () => { + this.dispose() + }, + null, + this.disposables, + ) + } + + private async handleWebviewMessage(message: WebPreviewMessage) { + switch (message.type) { + case "elementSelected": + if (message.elementContext) { + this.selectedElementContext = message.elementContext + await this.sendElementContextToCline(message.elementContext) + } + break + case "urlChanged": + this.currentUrl = message.url + break + case "viewportChanged": + // Handle viewport changes if needed + break + case "error": + this.outputChannel.appendLine(`Web Preview Error: ${message.error}`) + break + case "previewReady": + this.outputChannel.appendLine("Web preview ready") + break + } + } + + private async sendElementContextToCline(context: ElementContext) { + if (!this.clineProvider) { + return + } + + // Format the element context for the AI + const contextMessage = this.formatElementContext(context) + + // Send to Cline's chat + await this.clineProvider.postMessageToWebview({ + type: "webPreviewElementSelected", + text: contextMessage, + elementContext: context, + } as ExtensionMessage) + } + + private formatElementContext(context: ElementContext): string { + let message = "Selected element context:\n\n" + + message += `HTML:\n\`\`\`html\n${context.html}\n\`\`\`\n\n` + + if (context.css) { + message += `CSS:\n\`\`\`css\n${context.css}\n\`\`\`\n\n` + } + + message += `Position: ${context.position.x}, ${context.position.y} (${context.position.width}x${context.position.height})\n` + + if (context.selector) { + message += `CSS Selector: ${context.selector}\n` + } + + if (context.xpath) { + message += `XPath: ${context.xpath}\n` + } + + return message + } + + public async loadUrl(url: string) { + if (!this.view) { + return + } + + this.currentUrl = url + await this.view.webview.postMessage({ + type: "loadUrl", + url, + }) + } + + public async setViewport(width: number, height: number) { + if (!this.view) { + return + } + + await this.view.webview.postMessage({ + type: "setViewport", + width, + height, + }) + } + + public getSelectedElementContext(): ElementContext | undefined { + return this.selectedElementContext + } + + private getHtmlContent(webview: vscode.Webview): string { + const scriptUri = getUri(webview, this.context.extensionUri, [ + "src", + "core", + "webview", + "preview", + "preview.js", + ]) + const stylesUri = getUri(webview, this.context.extensionUri, [ + "src", + "core", + "webview", + "preview", + "preview.css", + ]) + const nonce = getNonce() + + return ` + + + + + + + Web Preview + + +
+ + + + +
+
+ + +
+ + +` + } + + public dispose() { + WebPreviewProvider.instance = undefined + while (this.disposables.length) { + const disposable = this.disposables.pop() + if (disposable) { + disposable.dispose() + } + } + this.view = undefined + } +} diff --git a/src/core/webview/preview/preview.css b/src/core/webview/preview/preview.css new file mode 100644 index 00000000000..ae08e0eaf46 --- /dev/null +++ b/src/core/webview/preview/preview.css @@ -0,0 +1,218 @@ +body { + margin: 0; + padding: 0; + font-family: var(--vscode-font-family); + background-color: var(--vscode-editor-background); + color: var(--vscode-editor-foreground); + display: flex; + flex-direction: column; + height: 100vh; + overflow: hidden; +} + +#controls { + display: flex; + align-items: center; + gap: 8px; + padding: 8px; + background-color: var(--vscode-editor-background); + border-bottom: 1px solid var(--vscode-panel-border); + flex-shrink: 0; +} + +#urlInput { + flex: 1; + padding: 4px 8px; + background-color: var(--vscode-input-background); + color: var(--vscode-input-foreground); + border: 1px solid var(--vscode-input-border); + border-radius: 2px; + font-family: var(--vscode-font-family); + font-size: 13px; +} + +#urlInput:focus { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +button { + padding: 4px 12px; + background-color: var(--vscode-button-background); + color: var(--vscode-button-foreground); + border: none; + border-radius: 2px; + cursor: pointer; + font-family: var(--vscode-font-family); + font-size: 13px; + white-space: nowrap; +} + +button:hover { + background-color: var(--vscode-button-hoverBackground); +} + +button:active { + transform: translateY(1px); +} + +#toggleInspector { + background-color: var(--vscode-button-secondaryBackground); + color: var(--vscode-button-secondaryForeground); +} + +#toggleInspector:hover { + background-color: var(--vscode-button-secondaryHoverBackground); +} + +#toggleInspector.active { + background-color: var(--vscode-button-background); + color: var(--vscode-button-foreground); +} + +select { + padding: 4px 8px; + background-color: var(--vscode-dropdown-background); + color: var(--vscode-dropdown-foreground); + border: 1px solid var(--vscode-dropdown-border); + border-radius: 2px; + font-family: var(--vscode-font-family); + font-size: 13px; + cursor: pointer; +} + +select:focus { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +#previewContainer { + flex: 1; + position: relative; + background-color: var(--vscode-editor-background); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +#preview { + background-color: white; + border: none; + box-shadow: 0 0 20px rgba(0, 0, 0, 0.1); + transition: + width 0.3s ease, + height 0.3s ease; +} + +#elementOverlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; + z-index: 1000; +} + +#elementOverlay.active { + pointer-events: auto; + cursor: crosshair; +} + +/* Responsive adjustments */ +@media (max-width: 600px) { + #controls { + flex-wrap: wrap; + } + + #urlInput { + width: 100%; + flex-basis: 100%; + } +} + +/* Loading indicator */ +.loading { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 14px; + color: var(--vscode-descriptionForeground); +} + +/* Error message */ +.error { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + padding: 16px; + background-color: var(--vscode-inputValidation-errorBackground); + border: 1px solid var(--vscode-inputValidation-errorBorder); + border-radius: 4px; + color: var(--vscode-errorForeground); + max-width: 80%; + text-align: center; +} + +/* Highlight overlay for element selection */ +.element-highlight { + position: absolute; + border: 2px solid #0066ff; + background-color: rgba(0, 102, 255, 0.1); + pointer-events: none; + z-index: 999999; + transition: all 0.1s ease; +} + +/* Tooltip for element info */ +.element-tooltip { + position: absolute; + background-color: var(--vscode-editorWidget-background); + border: 1px solid var(--vscode-editorWidget-border); + padding: 4px 8px; + border-radius: 3px; + font-size: 12px; + color: var(--vscode-editorWidget-foreground); + pointer-events: none; + z-index: 1000000; + white-space: nowrap; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); +} + +/* Device frame simulation */ +.device-frame { + position: relative; + margin: 20px auto; + border-radius: 20px; + box-shadow: 0 0 30px rgba(0, 0, 0, 0.3); + background-color: #1a1a1a; + padding: 10px; +} + +.device-frame.iphone { + border-radius: 30px; + padding: 15px; +} + +.device-frame.ipad { + border-radius: 20px; + padding: 20px; +} + +/* Viewport size indicator */ +.viewport-indicator { + position: absolute; + bottom: 10px; + right: 10px; + background-color: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); + padding: 2px 6px; + border-radius: 3px; + font-size: 11px; + font-family: var(--vscode-editor-font-family); + pointer-events: none; + z-index: 100; +} diff --git a/src/core/webview/preview/preview.js b/src/core/webview/preview/preview.js new file mode 100644 index 00000000000..8cbc329d3c0 --- /dev/null +++ b/src/core/webview/preview/preview.js @@ -0,0 +1,440 @@ +/* eslint-env browser */ +/* global acquireVsCodeApi */ + +;(function () { + const vscode = acquireVsCodeApi() + + let isInspectorMode = false + let currentHighlight = null + let iframe = null + let overlay = null + + // Device presets for responsive preview + const devicePresets = { + desktop: { width: "100%", height: "100%", name: "Desktop" }, + "iphone-14": { width: 390, height: 844, name: "iPhone 14" }, + "iphone-se": { width: 375, height: 667, name: "iPhone SE" }, + ipad: { width: 820, height: 1180, name: "iPad" }, + "pixel-7": { width: 412, height: 915, name: "Pixel 7" }, + "galaxy-s21": { width: 360, height: 800, name: "Galaxy S21" }, + } + + // Initialize when DOM is ready + document.addEventListener("DOMContentLoaded", () => { + iframe = document.getElementById("preview") + overlay = document.getElementById("elementOverlay") + + setupControls() + setupMessageHandlers() + + // Send ready message + vscode.postMessage({ type: "previewReady" }) + }) + + function setupControls() { + // URL input and go button + const urlInput = document.getElementById("urlInput") + const goButton = document.getElementById("goButton") + + goButton.addEventListener("click", () => { + const url = urlInput.value.trim() + if (url) { + loadUrl(url) + } + }) + + urlInput.addEventListener("keypress", (e) => { + if (e.key === "Enter") { + const url = urlInput.value.trim() + if (url) { + loadUrl(url) + } + } + }) + + // Device selector + const deviceSelector = document.getElementById("deviceSelector") + deviceSelector.addEventListener("change", (e) => { + const value = e.target.value + if (value === "responsive") { + iframe.style.width = "100%" + iframe.style.height = "100%" + } else { + const [width, height] = value.split("x") + setViewport(parseInt(width), parseInt(height)) + } + }) + + // Inspector toggle + const toggleInspector = document.getElementById("toggleInspector") + toggleInspector.addEventListener("click", () => { + isInspectorMode = !isInspectorMode + toggleInspector.classList.toggle("active", isInspectorMode) + + if (isInspectorMode) { + enableInspectorMode() + } else { + disableInspectorMode() + } + }) + } + + function setupMessageHandlers() { + window.addEventListener("message", (event) => { + const message = event.data + + switch (message.type) { + case "loadUrl": + loadUrl(message.url) + break + case "setViewport": + setViewport(message.width, message.height) + break + } + }) + } + + function applyDevicePreset(deviceKey, deviceFrame, iframe) { + const preset = devicePresets[deviceKey] + + if (deviceKey === "desktop") { + deviceFrame.className = "device-frame desktop" + deviceFrame.style.width = "" + deviceFrame.style.height = "" + iframe.style.width = "100%" + iframe.style.height = "100%" + } else { + deviceFrame.className = "device-frame mobile" + deviceFrame.style.width = preset.width + "px" + deviceFrame.style.height = preset.height + "px" + iframe.style.width = "100%" + iframe.style.height = "100%" + } + } + + function loadUrl(url) { + // Ensure URL has protocol + if (!url.startsWith("http://") && !url.startsWith("https://")) { + url = "http://" + url + } + + try { + iframe.src = url + document.getElementById("urlInput").value = url + + // Notify extension + vscode.postMessage({ + type: "urlChanged", + url: url, + }) + + // Setup iframe load handler + iframe.onload = () => { + if (isInspectorMode) { + injectInspectorScript() + } + } + } catch (error) { + vscode.postMessage({ + type: "error", + error: error.message, + }) + } + } + + function setViewport(width, height) { + iframe.style.width = width + "px" + iframe.style.height = height + "px" + + vscode.postMessage({ + type: "viewportChanged", + viewport: { width, height }, + }) + } + + function enableInspectorMode() { + overlay.style.display = "block" + overlay.style.pointerEvents = "auto" + + // Add click handler to overlay + overlay.addEventListener("click", handleOverlayClick) + overlay.addEventListener("mousemove", handleOverlayMouseMove) + + // Inject inspector script into iframe + injectInspectorScript() + } + + function disableInspectorMode() { + overlay.style.display = "none" + overlay.style.pointerEvents = "none" + + // Remove handlers + overlay.removeEventListener("click", handleOverlayClick) + overlay.removeEventListener("mousemove", handleOverlayMouseMove) + + // Clear highlight + if (currentHighlight) { + currentHighlight.remove() + currentHighlight = null + } + + // Remove inspector from iframe + try { + if (iframe.contentWindow) { + iframe.contentWindow.postMessage({ type: "disableInspector" }, "*") + } + } catch (e) { + // Cross-origin restriction + } + } + + function injectInspectorScript() { + try { + const iframeDoc = iframe.contentDocument || iframe.contentWindow.document + + // Check if we can access the iframe content + if (!iframeDoc) { + console.warn("Cannot access iframe content - cross-origin restriction") + return + } + + // Inject inspector script + const script = iframeDoc.createElement("script") + script.textContent = ` + (function() { + let hoveredElement = null; + let highlightDiv = null; + + function createHighlight() { + if (highlightDiv) { + highlightDiv.remove(); + } + + highlightDiv = document.createElement('div'); + highlightDiv.style.position = 'absolute'; + highlightDiv.style.border = '2px solid #0066ff'; + highlightDiv.style.backgroundColor = 'rgba(0, 102, 255, 0.1)'; + highlightDiv.style.pointerEvents = 'none'; + highlightDiv.style.zIndex = '999999'; + document.body.appendChild(highlightDiv); + } + + function updateHighlight(element) { + if (!highlightDiv) { + createHighlight(); + } + + const rect = element.getBoundingClientRect(); + highlightDiv.style.left = rect.left + window.scrollX + 'px'; + highlightDiv.style.top = rect.top + window.scrollY + 'px'; + highlightDiv.style.width = rect.width + 'px'; + highlightDiv.style.height = rect.height + 'px'; + } + + function getElementContext(element) { + const rect = element.getBoundingClientRect(); + const styles = window.getComputedStyle(element); + + // Get CSS rules + let cssRules = []; + try { + for (let sheet of document.styleSheets) { + for (let rule of sheet.cssRules) { + if (rule.selectorText && element.matches(rule.selectorText)) { + cssRules.push(rule.cssText); + } + } + } + } catch (e) { + // Cross-origin stylesheets + } + + // Get attributes + const attributes = {}; + for (let attr of element.attributes) { + attributes[attr.name] = attr.value; + } + + // Get XPath + function getXPath(el) { + if (el.id) return '//*[@id="' + el.id + '"]'; + if (el === document.body) return '/html/body'; + + let ix = 0; + const siblings = el.parentNode.childNodes; + for (let i = 0; i < siblings.length; i++) { + const sibling = siblings[i]; + if (sibling === el) { + return getXPath(el.parentNode) + '/' + el.tagName.toLowerCase() + '[' + (ix + 1) + ']'; + } + if (sibling.nodeType === 1 && sibling.tagName === el.tagName) { + ix++; + } + } + } + + // Get CSS selector + function getCssSelector(el) { + const path = []; + while (el.nodeType === Node.ELEMENT_NODE) { + let selector = el.nodeName.toLowerCase(); + if (el.id) { + selector += '#' + el.id; + path.unshift(selector); + break; + } else { + let sibling = el; + let nth = 1; + while (sibling = sibling.previousElementSibling) { + if (sibling.nodeName.toLowerCase() === selector) nth++; + } + if (nth !== 1) selector += ':nth-of-type(' + nth + ')'; + } + path.unshift(selector); + el = el.parentNode; + } + return path.join(' > '); + } + + return { + html: element.outerHTML, + css: cssRules.join('\\n'), + position: { + x: rect.left, + y: rect.top, + width: rect.width, + height: rect.height + }, + computedStyles: { + display: styles.display, + position: styles.position, + width: styles.width, + height: styles.height, + margin: styles.margin, + padding: styles.padding, + color: styles.color, + backgroundColor: styles.backgroundColor, + fontSize: styles.fontSize, + fontFamily: styles.fontFamily + }, + attributes: attributes, + xpath: getXPath(element), + selector: getCssSelector(element) + }; + } + + // Mouse move handler + document.addEventListener('mousemove', (e) => { + const element = document.elementFromPoint(e.clientX, e.clientY); + if (element && element !== hoveredElement) { + hoveredElement = element; + updateHighlight(element); + } + }); + + // Click handler + document.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + + const element = document.elementFromPoint(e.clientX, e.clientY); + if (element) { + const context = getElementContext(element); + + // Send to parent window + window.parent.postMessage({ + type: 'elementSelected', + context: context + }, '*'); + } + + return false; + }, true); + + // Listen for disable message + window.addEventListener('message', (e) => { + if (e.data.type === 'disableInspector') { + if (highlightDiv) { + highlightDiv.remove(); + highlightDiv = null; + } + } + }); + })(); + ` + + iframeDoc.body.appendChild(script) + } catch (error) { + console.error("Error injecting inspector script:", error) + vscode.postMessage({ + type: "error", + error: "Cannot inspect elements on this page due to security restrictions", + }) + } + } + + function handleOverlayClick(e) { + // Calculate position relative to iframe + const iframeRect = iframe.getBoundingClientRect() + const x = e.clientX - iframeRect.left + const y = e.clientY - iframeRect.top + + // Try to get element from iframe + try { + if (iframe.contentWindow) { + iframe.contentWindow.postMessage( + { + type: "click", + x: x, + y: y, + }, + "*", + ) + } + } catch (error) { + // Cross-origin restriction + vscode.postMessage({ + type: "error", + error: "Cannot inspect elements on cross-origin pages", + }) + } + } + + function handleOverlayMouseMove(e) { + // Similar to click, but for hover effects + const iframeRect = iframe.getBoundingClientRect() + const x = e.clientX - iframeRect.left + const y = e.clientY - iframeRect.top + + try { + if (iframe.contentWindow) { + iframe.contentWindow.postMessage( + { + type: "mousemove", + x: x, + y: y, + }, + "*", + ) + } + } catch (error) { + // Ignore cross-origin errors for mousemove + } + } + + // Listen for messages from iframe + window.addEventListener("message", (event) => { + if (event.data.type === "elementSelected" && event.data.context) { + // Forward to extension + vscode.postMessage({ + type: "elementSelected", + elementContext: event.data.context, + }) + + // Disable inspector mode after selection + isInspectorMode = false + document.getElementById("toggleInspector").classList.remove("active") + disableInspectorMode() + } + }) +})() diff --git a/src/eslint.config.mjs b/src/eslint.config.mjs index d0813406d98..e6bf975eccc 100644 --- a/src/eslint.config.mjs +++ b/src/eslint.config.mjs @@ -29,6 +29,12 @@ export default [ "no-undef": "off", }, }, + { + files: ["core/webview/preview/preview.js"], + rules: { + "no-undef": "off", + }, + }, { ignores: ["webview-ui", "out"], }, diff --git a/src/extension.ts b/src/extension.ts index bd43bcbf8a8..31a405419ee 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -22,6 +22,7 @@ import { Package } from "./shared/package" import { formatLanguage } from "./shared/language" import { ContextProxy } from "./core/config/ContextProxy" import { ClineProvider } from "./core/webview/ClineProvider" +import { WebPreviewProvider } from "./core/webview/WebPreviewProvider" import { DIFF_VIEW_URI_SCHEME } from "./integrations/editor/DiffViewProvider" import { TerminalRegistry } from "./integrations/terminal/TerminalRegistry" import { McpServerManager } from "./services/mcp/McpServerManager" @@ -121,6 +122,16 @@ export async function activate(context: vscode.ExtensionContext) { }), ) + // Register the web preview provider + const webPreviewProvider = new WebPreviewProvider(context, outputChannel) + webPreviewProvider.setClineProvider(provider) + + context.subscriptions.push( + vscode.window.registerWebviewViewProvider(WebPreviewProvider.viewId, webPreviewProvider, { + webviewOptions: { retainContextWhenHidden: true }, + }), + ) + // Auto-import configuration if specified in settings try { await autoImportSettings(outputChannel, { diff --git a/src/package.json b/src/package.json index 5e3cd3bc530..7a0687590b5 100644 --- a/src/package.json +++ b/src/package.json @@ -66,6 +66,12 @@ "type": "webview", "id": "roo-cline.SidebarProvider", "name": "%views.sidebar.name%" + }, + { + "type": "webview", + "id": "roo-cline.WebPreviewProvider", + "name": "Web Preview", + "visibility": "collapsed" } ] }, @@ -174,6 +180,12 @@ "command": "roo-cline.acceptInput", "title": "%command.acceptInput.title%", "category": "%configuration.title%" + }, + { + "command": "roo-cline.openWebPreview", + "title": "Open Web Preview", + "category": "%configuration.title%", + "icon": "$(globe)" } ], "menus": { diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 4f2aa2da159..89376dbd312 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -107,6 +107,7 @@ export interface ExtensionMessage { | "codeIndexSecretStatus" | "showDeleteMessageDialog" | "showEditMessageDialog" + | "webPreviewElementSelected" text?: string payload?: any // Add a generic payload for now, can refine later action?: @@ -161,6 +162,7 @@ export interface ExtensionMessage { settings?: any messageTs?: number context?: string + elementContext?: any // For web preview element context } export type ExtensionState = Pick< diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index f804f7b61ed..5ed89a0bef3 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -758,6 +758,13 @@ const ChatViewComponent: React.ForwardRefRenderFunction Date: Sun, 20 Jul 2025 05:46:16 +0000 Subject: [PATCH 2/3] fix: address CodeQL security vulnerabilities in URL handling - Validate and sanitize URLs before loading in iframe - Use URL constructor to parse and validate URLs - Only allow HTTP and HTTPS protocols - Use setAttribute instead of direct property assignment - Add proper error handling for invalid URLs This fixes: - Client-side URL redirect vulnerability - DOM text reinterpreted as HTML - Client-side cross-site scripting (XSS) --- src/core/webview/preview/preview.js | 34 +++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/src/core/webview/preview/preview.js b/src/core/webview/preview/preview.js index 8cbc329d3c0..38cf894da21 100644 --- a/src/core/webview/preview/preview.js +++ b/src/core/webview/preview/preview.js @@ -113,19 +113,35 @@ } function loadUrl(url) { - // Ensure URL has protocol - if (!url.startsWith("http://") && !url.startsWith("https://")) { - url = "http://" + url - } - + // Validate and sanitize URL try { - iframe.src = url - document.getElementById("urlInput").value = url + // Ensure URL has protocol + if (!url.startsWith("http://") && !url.startsWith("https://")) { + url = "http://" + url + } + + // Parse and validate URL + const parsedUrl = new URL(url) + + // Only allow http and https protocols + if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") { + throw new Error("Only HTTP and HTTPS protocols are allowed") + } + + // Create a safe URL string + const safeUrl = parsedUrl.toString() + + // Set iframe source using setAttribute for better security + iframe.setAttribute("src", safeUrl) + + // Update input field with the safe URL + const urlInput = document.getElementById("urlInput") + urlInput.value = safeUrl // Notify extension vscode.postMessage({ type: "urlChanged", - url: url, + url: safeUrl, }) // Setup iframe load handler @@ -137,7 +153,7 @@ } catch (error) { vscode.postMessage({ type: "error", - error: error.message, + error: "Invalid URL: " + error.message, }) } } From a125c4bcb536575426be80b9981d97c535f2a809 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Sun, 20 Jul 2025 05:52:16 +0000 Subject: [PATCH 3/3] fix: resolve viewId inconsistency in WebPreviewProvider - Changed viewId from "roo-code.webPreview" to "roo-cline.WebPreviewProvider" to match package.json and registerCommands.ts - This fixes the issue identified by ellipsis-dev bot in PR #5981 - Security vulnerabilities were already fixed in the original PR --- src/core/webview/WebPreviewProvider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/webview/WebPreviewProvider.ts b/src/core/webview/WebPreviewProvider.ts index 01c4c3eae41..97ecb1986c0 100644 --- a/src/core/webview/WebPreviewProvider.ts +++ b/src/core/webview/WebPreviewProvider.ts @@ -29,7 +29,7 @@ export interface WebPreviewMessage { } export class WebPreviewProvider implements vscode.WebviewViewProvider { - public static readonly viewId = "roo-code.webPreview" + public static readonly viewId = "roo-cline.WebPreviewProvider" private static instance?: WebPreviewProvider private view?: vscode.WebviewView