diff --git a/README.md b/README.md index e94f0d884a1..4f38ebd49df 100644 --- a/README.md +++ b/README.md @@ -93,9 +93,27 @@ Roo Code comes with powerful [tools](https://docs.roocode.com/basic-usage/how-to - Execute commands in your VS Code terminal - Control a web browser - Use external tools via [MCP (Model Context Protocol)](https://docs.roocode.com/advanced-usage/mcp) +- **Preview web applications** with integrated element selection for AI context 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. +### Web Preview + +The integrated web preview feature allows you to: + +- **Preview web applications** directly within VS Code +- **Select UI elements** to automatically capture their context (HTML, CSS, XPath, position) +- **Send element context to AI** for better communication about specific UI components +- **Test responsive designs** with device simulation (Desktop, Laptop, iPad, iPhone, etc.) +- **Navigate seamlessly** between different pages of your application + +To use the web preview: + +1. Right-click on any HTML file and select "Open Web Preview" +2. Or use the command palette: `Roo Code: Open Web Preview` +3. Click "Select Element" to enable element selection mode +4. Click on any element in the preview to send its context to the AI assistant + ### Customization Make Roo Code work your way with: diff --git a/assets/icons/browser_dark.svg b/assets/icons/browser_dark.svg new file mode 100644 index 00000000000..f2d770b4545 --- /dev/null +++ b/assets/icons/browser_dark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/assets/icons/browser_light.svg b/assets/icons/browser_light.svg new file mode 100644 index 00000000000..1254ad7f774 --- /dev/null +++ b/assets/icons/browser_light.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file 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/activate/registerCommands.ts b/src/activate/registerCommands.ts index bd925b0e900..9e63fb6d6b5 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -218,6 +218,47 @@ const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOpt visibleProvider.postMessageToWebview({ type: "acceptInput" }) }, + openWebPreview: async () => { + const { WebPreviewProvider } = await import("../core/webview/WebPreviewProvider") + + const contextProxy = await ContextProxy.getInstance(context) + const visibleProvider = getVisibleProviderOrLog(outputChannel) + + if (!visibleProvider) { + return + } + + const webPreviewProvider = new WebPreviewProvider(context, outputChannel, contextProxy, visibleProvider) + + const panel = vscode.window.createWebviewPanel( + WebPreviewProvider.viewId, + "Web Preview", + vscode.ViewColumn.Two, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [context.extensionUri], + }, + ) + + panel.iconPath = { + light: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "browser_light.svg"), + dark: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "browser_dark.svg"), + } + + await webPreviewProvider.resolveWebviewView(panel) + + // Handle panel disposal + panel.onDidDispose( + () => { + webPreviewProvider.dispose() + }, + null, + context.subscriptions, + ) + + TelemetryService.instance.captureTitleButtonClicked("webPreview") + }, }) 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..bd2f3ddb3e4 --- /dev/null +++ b/src/core/webview/WebPreviewProvider.ts @@ -0,0 +1,274 @@ +import * as vscode from "vscode" +import * as path from "path" +import { EventEmitter } from "events" + +import { Package } from "../../shared/package" +import { ExtensionMessage } from "../../shared/ExtensionMessage" +import { WebviewMessage } from "../../shared/WebviewMessage" +import { ClineProvider } from "./ClineProvider" +import { ContextProxy } from "../config/ContextProxy" +import { getNonce } from "./getNonce" +import { getUri } from "./getUri" + +export interface WebPreviewElement { + html: string + css: string + xpath: string + selector: string + position: { + x: number + y: number + width: number + height: number + } + computedStyles?: Record + attributes?: Record +} + +export type WebPreviewProviderEvents = { + elementSelected: [element: WebPreviewElement] +} + +export class WebPreviewProvider extends EventEmitter implements vscode.WebviewViewProvider { + public static readonly viewId = `${Package.name}.WebPreviewProvider` + private view?: vscode.WebviewView | vscode.WebviewPanel + private disposables: vscode.Disposable[] = [] + private webviewDisposables: vscode.Disposable[] = [] + private currentUrl?: string + private selectedElement?: WebPreviewElement + + constructor( + private readonly context: vscode.ExtensionContext, + private readonly outputChannel: vscode.OutputChannel, + private readonly contextProxy: ContextProxy, + private readonly clineProvider: ClineProvider, + ) { + super() + this.log("WebPreviewProvider instantiated") + } + + async resolveWebviewView(webviewView: vscode.WebviewView | vscode.WebviewPanel) { + this.log("Resolving web preview view") + this.view = webviewView + + webviewView.webview.options = { + enableScripts: true, + localResourceRoots: [this.contextProxy.extensionUri], + } + + webviewView.webview.html = this.getHtmlContent(webviewView.webview) + this.setWebviewMessageListener(webviewView.webview) + + // Listen for visibility changes + if ("onDidChangeViewState" in webviewView) { + const viewStateDisposable = webviewView.onDidChangeViewState(() => { + if (this.view?.visible) { + this.postMessageToWebview({ type: "action", action: "didBecomeVisible" }) + } + }) + this.webviewDisposables.push(viewStateDisposable) + } else if ("onDidChangeVisibility" in webviewView) { + const visibilityDisposable = webviewView.onDidChangeVisibility(() => { + if (this.view?.visible) { + this.postMessageToWebview({ type: "action", action: "didBecomeVisible" }) + } + }) + this.webviewDisposables.push(visibilityDisposable) + } + + // Handle disposal + webviewView.onDidDispose( + async () => { + this.clearWebviewResources() + }, + null, + this.disposables, + ) + + this.log("Web preview view resolved") + } + + private clearWebviewResources() { + while (this.webviewDisposables.length) { + const x = this.webviewDisposables.pop() + if (x) { + x.dispose() + } + } + } + + async dispose() { + this.log("Disposing WebPreviewProvider...") + + if (this.view && "dispose" in this.view) { + this.view.dispose() + this.log("Disposed webview") + } + + this.clearWebviewResources() + + while (this.disposables.length) { + const x = this.disposables.pop() + if (x) { + x.dispose() + } + } + + this.log("Disposed all disposables") + } + + public async postMessageToWebview(message: ExtensionMessage) { + await this.view?.webview.postMessage(message) + } + + private setWebviewMessageListener(webview: vscode.Webview) { + const onReceiveMessage = async (message: WebviewMessage) => { + switch (message.type) { + case "webPreviewReady": + this.log("Web preview ready") + // Send initial configuration + await this.postMessageToWebview({ + type: "webPreviewConfig", + config: { + defaultUrl: "http://localhost:3000", + enableDeviceSimulation: true, + }, + }) + break + + case "webPreviewNavigate": + if (message.url) { + this.currentUrl = message.url + this.log(`Navigating to: ${message.url}`) + } + break + + case "webPreviewElementSelected": + if (message.element) { + this.selectedElement = message.element as WebPreviewElement + this.emit("elementSelected", this.selectedElement) + + // Send element context to Cline + await this.sendElementContextToCline(this.selectedElement) + } + break + + case "webPreviewError": + this.log(`Web preview error: ${message.error}`) + vscode.window.showErrorMessage(`Web Preview Error: ${message.error}`) + break + } + } + + const messageDisposable = webview.onDidReceiveMessage(onReceiveMessage) + this.webviewDisposables.push(messageDisposable) + } + + private async sendElementContextToCline(element: WebPreviewElement) { + // Format element context for AI + const context = this.formatElementContext(element) + + // Send to Cline provider + await this.clineProvider.postMessageToWebview({ + type: "webPreviewElementContext", + context, + }) + } + + private formatElementContext(element: WebPreviewElement): string { + let context = "Selected Element Context:\n\n" + + // HTML structure + context += `HTML:\n${element.html}\n\n` + + // CSS selector + context += `CSS Selector: ${element.selector}\n` + context += `XPath: ${element.xpath}\n\n` + + // Position + context += `Position: ${element.position.x}px, ${element.position.y}px\n` + context += `Size: ${element.position.width}px × ${element.position.height}px\n\n` + + // Computed styles (if available) + if (element.computedStyles) { + context += "Key Styles:\n" + const importantStyles = ["display", "position", "width", "height", "color", "background-color", "font-size"] + for (const style of importantStyles) { + if (element.computedStyles[style]) { + context += ` ${style}: ${element.computedStyles[style]}\n` + } + } + context += "\n" + } + + // Attributes + if (element.attributes) { + context += "Attributes:\n" + for (const [key, value] of Object.entries(element.attributes)) { + context += ` ${key}: ${value}\n` + } + } + + return context + } + + private getHtmlContent(webview: vscode.Webview): string { + const scriptUri = getUri(webview, this.contextProxy.extensionUri, [ + "webview-ui", + "build", + "assets", + "webPreview.js", + ]) + const stylesUri = getUri(webview, this.contextProxy.extensionUri, [ + "webview-ui", + "build", + "assets", + "index.css", + ]) + const codiconsUri = getUri(webview, this.contextProxy.extensionUri, ["assets", "codicons", "codicon.css"]) + + const nonce = getNonce() + + return /*html*/ ` + + + + + + + + + Web Preview + + +
+ + + + ` + } + + public async navigateToUrl(url: string) { + this.currentUrl = url + await this.postMessageToWebview({ + type: "webPreviewNavigate", + url, + }) + } + + public async setDeviceMode(device: string) { + await this.postMessageToWebview({ + type: "webPreviewSetDevice", + device, + }) + } + + public getSelectedElement(): WebPreviewElement | undefined { + return this.selectedElement + } + + private log(message: string) { + this.outputChannel.appendLine(`[WebPreview] ${message}`) + console.log(`[WebPreview] ${message}`) + } +} diff --git a/src/core/webview/__tests__/WebPreviewProvider.spec.ts b/src/core/webview/__tests__/WebPreviewProvider.spec.ts new file mode 100644 index 00000000000..1bb47c939f1 --- /dev/null +++ b/src/core/webview/__tests__/WebPreviewProvider.spec.ts @@ -0,0 +1,319 @@ +// npx vitest core/webview/__tests__/WebPreviewProvider.spec.ts + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import * as vscode from "vscode" +import { WebPreviewProvider } from "../WebPreviewProvider" +import { EventEmitter } from "events" + +// Mock vscode module +vi.mock("vscode", () => ({ + window: { + createWebviewPanel: vi.fn(), + showErrorMessage: vi.fn(), + showInformationMessage: vi.fn(), + }, + ViewColumn: { + Two: 2, + }, + Uri: { + file: vi.fn((path: string) => ({ fsPath: path })), + joinPath: vi.fn((uri: any, ...paths: string[]) => ({ + fsPath: [uri.fsPath, ...paths].join("/"), + })), + }, + Webview: vi.fn(), + WebviewPanel: vi.fn(), + WebviewView: vi.fn(), + EventEmitter: vi.fn(() => ({ + fire: vi.fn(), + event: vi.fn(), + })), + Disposable: { + from: vi.fn(), + }, +})) + +// Mock fs module +vi.mock("fs/promises", () => ({ + readFile: vi.fn().mockResolvedValue("Test"), +})) + +// Mock getUri and getNonce +vi.mock("../getUri", () => ({ + getUri: vi.fn((webview: any, extensionUri: any, pathList: string[]) => { + return `vscode-resource://${pathList.join("/")}` + }), +})) + +vi.mock("../getNonce", () => ({ + getNonce: vi.fn(() => "test-nonce-12345"), +})) + +describe("WebPreviewProvider", () => { + let provider: WebPreviewProvider + let mockWebview: any + let mockWebviewView: any + let mockContext: any + let mockOutputChannel: any + let mockContextProxy: any + let mockClineProvider: any + + beforeEach(() => { + // Setup mock webview + mockWebview = { + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + postMessage: vi.fn(), + asWebviewUri: vi.fn((uri: any) => uri), + cspSource: "vscode-resource:", + } + + // Setup mock webview view + mockWebviewView = { + webview: mockWebview, + onDidDispose: vi.fn(), + onDidChangeViewState: vi.fn(), + onDidChangeVisibility: vi.fn(), + visible: true, + } + + // Setup mock context + mockContext = { + extensionUri: { fsPath: "/test/extension" }, + subscriptions: [], + } + + // Setup mock output channel + mockOutputChannel = { + appendLine: vi.fn(), + } + + // Setup mock context proxy + mockContextProxy = { + extensionUri: { fsPath: "/test/extension" }, + } + + // Setup mock cline provider + mockClineProvider = { + postMessageToWebview: vi.fn(), + } + + // Create provider instance + provider = new WebPreviewProvider(mockContext, mockOutputChannel, mockContextProxy, mockClineProvider) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe("resolveWebviewView", () => { + it("should set webview options and HTML content", async () => { + await provider.resolveWebviewView(mockWebviewView) + + expect(mockWebview.options).toEqual({ + enableScripts: true, + localResourceRoots: [mockContextProxy.extensionUri], + }) + expect(mockWebview.html).toContain("") + expect(mockWebview.html).toContain('
') + expect(mockWebview.html).toContain("webPreview.js") + }) + + it("should set up message listener", async () => { + await provider.resolveWebviewView(mockWebviewView) + + expect(mockWebview.onDidReceiveMessage).toHaveBeenCalled() + }) + + it("should set up visibility change listener", async () => { + await provider.resolveWebviewView(mockWebviewView) + + expect(mockWebviewView.onDidChangeViewState).toHaveBeenCalled() + }) + + it("should set up disposal listener", async () => { + await provider.resolveWebviewView(mockWebviewView) + + expect(mockWebviewView.onDidDispose).toHaveBeenCalled() + }) + }) + + describe("postMessageToWebview", () => { + it("should post message to webview when view exists", async () => { + await provider.resolveWebviewView(mockWebviewView) + const message = { type: "webPreviewNavigate" as const, url: "https://example.com" } + + await provider.postMessageToWebview(message) + + expect(mockWebview.postMessage).toHaveBeenCalledWith(message) + }) + + it("should not throw when view does not exist", async () => { + const message = { type: "webPreviewNavigate" as const, url: "https://example.com" } + + await expect(provider.postMessageToWebview(message)).resolves.not.toThrow() + }) + }) + + describe("handleWebviewMessage", () => { + it("should handle webPreviewReady message", async () => { + await provider.resolveWebviewView(mockWebviewView) + + // Get the message handler + const messageHandler = mockWebview.onDidReceiveMessage.mock.calls[0][0] + + // Simulate webPreviewReady message + await messageHandler({ type: "webPreviewReady" }) + + expect(mockWebview.postMessage).toHaveBeenCalledWith({ + type: "webPreviewConfig", + config: { + defaultUrl: "http://localhost:3000", + enableDeviceSimulation: true, + }, + }) + }) + + it("should handle webPreviewNavigate message", async () => { + await provider.resolveWebviewView(mockWebviewView) + + const messageHandler = mockWebview.onDidReceiveMessage.mock.calls[0][0] + const url = "https://example.com" + + await messageHandler({ type: "webPreviewNavigate", url }) + + // Should log navigation + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith(expect.stringContaining(`Navigating to: ${url}`)) + }) + + it("should handle webPreviewElementSelected message", async () => { + await provider.resolveWebviewView(mockWebviewView) + + const messageHandler = mockWebview.onDidReceiveMessage.mock.calls[0][0] + const element = { + html: "
Test
", + css: "", + selector: "div", + xpath: "/html/body/div", + position: { x: 10, y: 20, width: 100, height: 40 }, + } + + // Set up event listener + let emittedElement: any + provider.on("elementSelected", (el) => { + emittedElement = el + }) + + await messageHandler({ type: "webPreviewElementSelected", element }) + + // Should emit element selected event + expect(emittedElement).toEqual(element) + + // Should send to cline provider + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "webPreviewElementContext", + context: expect.stringContaining("Selected Element Context"), + }) + }) + + it("should handle webPreviewError message", async () => { + await provider.resolveWebviewView(mockWebviewView) + + const messageHandler = mockWebview.onDidReceiveMessage.mock.calls[0][0] + const error = "Failed to load page" + + await messageHandler({ type: "webPreviewError", error }) + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(`Web Preview Error: ${error}`) + }) + }) + + describe("navigateToUrl", () => { + it("should send navigation message to webview", async () => { + await provider.resolveWebviewView(mockWebviewView) + const url = "https://example.com" + + await provider.navigateToUrl(url) + + expect(mockWebview.postMessage).toHaveBeenCalledWith({ + type: "webPreviewNavigate", + url, + }) + }) + }) + + describe("setDeviceMode", () => { + it("should send device mode message to webview", async () => { + await provider.resolveWebviewView(mockWebviewView) + const device = "iPhone 14" + + await provider.setDeviceMode(device) + + expect(mockWebview.postMessage).toHaveBeenCalledWith({ + type: "webPreviewSetDevice", + device, + }) + }) + }) + + describe("getSelectedElement", () => { + it("should return selected element after selection", async () => { + await provider.resolveWebviewView(mockWebviewView) + + const messageHandler = mockWebview.onDidReceiveMessage.mock.calls[0][0] + const element = { + html: "", + css: "", + selector: "button", + xpath: "/html/body/button", + position: { x: 10, y: 20, width: 100, height: 40 }, + } + + await messageHandler({ type: "webPreviewElementSelected", element }) + + const selected = provider.getSelectedElement() + expect(selected).toEqual(element) + }) + + it("should return undefined when no element selected", () => { + const selected = provider.getSelectedElement() + expect(selected).toBeUndefined() + }) + }) + + describe("dispose", () => { + it("should dispose webview when provider is disposed", async () => { + // Create a mock panel instead of view for disposal test + const mockPanel = { + ...mockWebviewView, + dispose: vi.fn(), + } + + await provider.resolveWebviewView(mockPanel) + + await provider.dispose() + + expect(mockPanel.dispose).toHaveBeenCalled() + }) + + it("should handle dispose when no view exists", async () => { + await expect(provider.dispose()).resolves.not.toThrow() + }) + }) + + describe("getHtmlContent", () => { + it("should generate correct HTML with CSP and nonce", async () => { + await provider.resolveWebviewView(mockWebviewView) + + const html = mockWebview.html + + expect(html).toContain("") + expect(html).toContain('
') + expect(html).toContain("webPreview.js") + expect(html).toContain("test-nonce-12345") + expect(html).toContain("Content-Security-Policy") + expect(html).toContain("frame-src https: http:") + }) + }) +}) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 780d40df891..740f9e6aac0 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -2239,5 +2239,19 @@ export const webviewMessageHandler = async ( } break } + case "webPreviewElementSelected": + // Handle web preview element context + if (message.text) { + const cline = provider.getCurrentCline() + if (cline) { + // Add the element context to the current conversation + await provider.postMessageToWebview({ + type: "invoke", + invoke: "setChatBoxMessage", + text: `Selected element context:\n\n${message.text}`, + }) + } + } + break } } diff --git a/src/package.json b/src/package.json index 5e3cd3bc530..ced9e30bff1 100644 --- a/src/package.json +++ b/src/package.json @@ -174,6 +174,12 @@ "command": "roo-cline.acceptInput", "title": "%command.acceptInput.title%", "category": "%configuration.title%" + }, + { + "command": "roo-cline.openWebPreview", + "title": "Open Web Preview", + "category": "%configuration.title%", + "icon": "$(browser)" } ], "menus": { @@ -195,6 +201,10 @@ { "command": "roo-cline.improveCode", "group": "1_actions@3" + }, + { + "command": "roo-cline.openWebPreview", + "group": "1_actions@4" } ], "terminal/context": [ diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 4f2aa2da159..1d427526309 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -107,6 +107,10 @@ export interface ExtensionMessage { | "codeIndexSecretStatus" | "showDeleteMessageDialog" | "showEditMessageDialog" + | "webPreviewConfig" + | "webPreviewElementContext" + | "webPreviewNavigate" + | "webPreviewSetDevice" text?: string payload?: any // Add a generic payload for now, can refine later action?: @@ -161,6 +165,9 @@ export interface ExtensionMessage { settings?: any messageTs?: number context?: string + config?: any + url?: string + device?: string } export type ExtensionState = Pick< diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 1f56829f7b3..c3052673037 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -198,6 +198,10 @@ export interface WebviewMessage { | "checkRulesDirectoryResult" | "saveCodeIndexSettingsAtomic" | "requestCodeIndexSecretStatus" + | "webPreviewReady" + | "webPreviewNavigate" + | "webPreviewElementSelected" + | "webPreviewError" text?: string editedMessageContent?: string tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "account" @@ -260,6 +264,8 @@ export interface WebviewMessage { codebaseIndexGeminiApiKey?: string codebaseIndexMistralApiKey?: string } + element?: any + error?: string } export const checkoutDiffPayloadSchema = z.object({ diff --git a/webview-ui/src/components/webpreview/WebPreviewView.css b/webview-ui/src/components/webpreview/WebPreviewView.css new file mode 100644 index 00000000000..9565e63feb0 --- /dev/null +++ b/webview-ui/src/components/webpreview/WebPreviewView.css @@ -0,0 +1,140 @@ +.web-preview-container { + display: flex; + flex-direction: column; + height: 100vh; + background-color: var(--vscode-editor-background); + color: var(--vscode-editor-foreground); +} + +.web-preview-header { + display: flex; + flex-direction: column; + gap: 10px; + padding: 10px; + background-color: var(--vscode-editorWidget-background); + border-bottom: 1px solid var(--vscode-widget-border); +} + +.url-bar { + display: flex; + gap: 10px; + align-items: center; +} + +.url-input { + flex: 1; +} + +.controls { + display: flex; + gap: 10px; + align-items: center; +} + +.web-preview-content { + flex: 1; + overflow: auto; + padding: 20px; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--vscode-editor-background); +} + +.device-frame { + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); + background-color: white; + border-radius: 8px; + overflow: hidden; + transition: transform 0.3s ease; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .web-preview-header { + flex-direction: column; + } + + .controls { + flex-wrap: wrap; + } +} + +/* Loading state */ +.loading-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.loading-spinner { + width: 40px; + height: 40px; + border: 4px solid var(--vscode-progressBar-background); + border-top-color: var(--vscode-focusBorder); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Element selection mode */ +.selecting-mode .device-frame { + cursor: crosshair; +} + +.element-overlay { + position: absolute; + border: 2px solid var(--vscode-focusBorder); + background: rgba(0, 122, 204, 0.1); + pointer-events: none; + z-index: 9999; +} + +.element-info { + position: fixed; + bottom: 20px; + right: 20px; + background-color: var(--vscode-editorWidget-background); + border: 1px solid var(--vscode-widget-border); + border-radius: 4px; + padding: 10px; + max-width: 300px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + z-index: 1001; + font-size: 12px; +} + +.url-input input { + width: 100%; +} + +.device-frame iframe { + width: 100%; + height: 100%; + border: none; +} + +.element-info h4 { + margin: 0 0 5px 0; + color: var(--vscode-foreground); +} + +.element-info pre { + margin: 0; + padding: 5px; + background-color: var(--vscode-editor-background); + border-radius: 2px; + overflow-x: auto; +} diff --git a/webview-ui/src/components/webpreview/WebPreviewView.tsx b/webview-ui/src/components/webpreview/WebPreviewView.tsx new file mode 100644 index 00000000000..70d9158fc52 --- /dev/null +++ b/webview-ui/src/components/webpreview/WebPreviewView.tsx @@ -0,0 +1,327 @@ +import React, { useState, useRef, useEffect, useCallback } from "react" +import { VSCodeButton, VSCodeDropdown, VSCodeOption, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" +import { vscode } from "../../utils/vscode" +import "./WebPreviewView.css" + +interface Device { + name: string + width: number + height: number + userAgent: string +} + +const DEVICES: Device[] = [ + { + name: "Desktop", + width: 1920, + height: 1080, + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + }, + { + name: "Laptop", + width: 1366, + height: 768, + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36", + }, + { + name: "iPad", + width: 768, + height: 1024, + userAgent: "Mozilla/5.0 (iPad; CPU OS 14_0 like Mac OS X) AppleWebKit/605.1.15", + }, + { + name: "iPhone 14", + width: 390, + height: 844, + userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15", + }, + { + name: "Pixel 5", + width: 393, + height: 851, + userAgent: "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36", + }, +] + +export const WebPreviewView: React.FC = () => { + const [url, setUrl] = useState("http://localhost:3000") + const [selectedDevice, setSelectedDevice] = useState(DEVICES[0]) + const [isSelecting, setIsSelecting] = useState(false) + const [scale, setScale] = useState(1) + const iframeRef = useRef(null) + const containerRef = useRef(null) + + // Handle messages from extension + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + const message = event.data + switch (message.type) { + case "webPreviewConfig": + if (message.config?.defaultUrl) { + setUrl(message.config.defaultUrl) + } + break + case "webPreviewNavigate": + if (message.url) { + setUrl(message.url) + if (iframeRef.current) { + iframeRef.current.src = message.url + } + } + break + case "webPreviewSetDevice": { + const device = DEVICES.find((d) => d.name === message.device) + if (device) { + setSelectedDevice(device) + } + break + } + } + } + + window.addEventListener("message", handleMessage) + return () => window.removeEventListener("message", handleMessage) + }, []) + + // Notify extension when ready + useEffect(() => { + vscode.postMessage({ type: "webPreviewReady" }) + }, []) + + // Calculate scale to fit device in container + useEffect(() => { + const updateScale = () => { + if (containerRef.current) { + const containerWidth = containerRef.current.clientWidth - 40 // padding + const containerHeight = containerRef.current.clientHeight - 120 // header height + const scaleX = containerWidth / selectedDevice.width + const scaleY = containerHeight / selectedDevice.height + setScale(Math.min(scaleX, scaleY, 1)) + } + } + + updateScale() + window.addEventListener("resize", updateScale) + return () => window.removeEventListener("resize", updateScale) + }, [selectedDevice]) + + const handleNavigate = useCallback(() => { + if (iframeRef.current) { + iframeRef.current.src = url + vscode.postMessage({ type: "webPreviewNavigate", url }) + } + }, [url]) + + const handleDeviceChange = useCallback((e: any) => { + const device = DEVICES.find((d) => d.name === e.target.value) + if (device) { + setSelectedDevice(device) + } + }, []) + + const toggleElementSelection = useCallback(() => { + setIsSelecting(!isSelecting) + if (!isSelecting && iframeRef.current) { + // Inject element selection script into iframe + try { + const script = ` + (function() { + let overlay = null; + let selectedElement = null; + + function createOverlay() { + overlay = document.createElement('div'); + overlay.style.position = 'absolute'; + overlay.style.border = '2px solid #007ACC'; + overlay.style.backgroundColor = 'rgba(0, 122, 204, 0.1)'; + overlay.style.pointerEvents = 'none'; + overlay.style.zIndex = '999999'; + document.body.appendChild(overlay); + } + + function updateOverlay(element) { + if (!overlay) createOverlay(); + const rect = element.getBoundingClientRect(); + overlay.style.left = rect.left + 'px'; + overlay.style.top = rect.top + 'px'; + overlay.style.width = rect.width + 'px'; + overlay.style.height = rect.height + 'px'; + } + + function removeOverlay() { + if (overlay) { + overlay.remove(); + overlay = null; + } + } + + function getElementContext(element) { + const rect = element.getBoundingClientRect(); + const styles = window.getComputedStyle(element); + const attributes = {}; + for (let attr of element.attributes) { + attributes[attr.name] = attr.value; + } + + return { + html: element.outerHTML, + css: element.getAttribute('style') || '', + xpath: getXPath(element), + selector: getSelector(element), + 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, + color: styles.color, + 'background-color': styles.backgroundColor, + 'font-size': styles.fontSize + }, + attributes: attributes + }; + } + + function getXPath(element) { + if (element.id) return '//*[@id="' + element.id + '"]'; + if (element === document.body) return '/html/body'; + + let ix = 0; + const siblings = element.parentNode.childNodes; + for (let i = 0; i < siblings.length; i++) { + const sibling = siblings[i]; + if (sibling === element) { + return getXPath(element.parentNode) + '/' + element.tagName.toLowerCase() + '[' + (ix + 1) + ']'; + } + if (sibling.nodeType === 1 && sibling.tagName === element.tagName) { + ix++; + } + } + } + + function getSelector(element) { + const names = []; + while (element.parentElement) { + if (element.id) { + names.unshift('#' + element.id); + break; + } else { + let c = 1, e = element; + for (; e.previousElementSibling; e = e.previousElementSibling, c++); + names.unshift(element.tagName.toLowerCase() + ':nth-child(' + c + ')'); + } + element = element.parentElement; + } + return names.join(' > '); + } + + document.addEventListener('mouseover', function(e) { + if (e.target !== overlay) { + updateOverlay(e.target); + selectedElement = e.target; + } + }); + + document.addEventListener('mouseout', function(e) { + removeOverlay(); + }); + + document.addEventListener('click', function(e) { + e.preventDefault(); + e.stopPropagation(); + if (selectedElement) { + const context = getElementContext(selectedElement); + window.parent.postMessage({ + type: 'elementSelected', + element: context + }, '*'); + } + return false; + }, true); + })(); + ` + + const iframeWindow = iframeRef.current.contentWindow as any + if (iframeWindow && iframeWindow.eval) { + iframeWindow.eval(script) + } + } catch (error) { + console.error("Failed to inject selection script:", error) + vscode.postMessage({ type: "webPreviewError", error: "Failed to enable element selection" }) + } + } + }, [isSelecting]) + + // Handle messages from iframe + useEffect(() => { + const handleIframeMessage = (event: MessageEvent) => { + if (event.data.type === "elementSelected") { + vscode.postMessage({ + type: "webPreviewElementSelected", + element: event.data.element, + }) + setIsSelecting(false) + } + } + + window.addEventListener("message", handleIframeMessage) + return () => window.removeEventListener("message", handleIframeMessage) + }, []) + + return ( +
+
+
+ setUrl(e.target.value)} + onKeyPress={(e: any) => e.key === "Enter" && handleNavigate()} + placeholder="Enter URL..." + className="url-input" + /> + Go +
+
+ + {DEVICES.map((device) => ( + + {device.name} ({device.width}x{device.height}) + + ))} + + + {isSelecting ? "Cancel Selection" : "Select Element"} + +
+
+
+
+