feat: add integrated web preview with element selection for AI context

- Implement WebPreviewProvider to manage webview lifecycle
- Create WebPreviewView React component with device simulation
- Add element selection mode with DOM inspection capabilities
- Extract element context (HTML, CSS, XPath, position, styles)
- Integrate with AI prompt system via webviewMessageHandler
- Support responsive design testing with device presets
- Add command registration and context menu integration
- Include comprehensive tests for provider and component
- Update documentation with usage instructions

Fixes #5971
This commit is contained in:
Roo Code 2025-07-20 05:11:29 +00:00
parent de13d8a5cf
commit fd0ed5ae12
17 changed files with 1446 additions and 0 deletions

View file

@ -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:

View file

@ -0,0 +1,10 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="3" width="14" height="11" rx="1" stroke="#CCCCCC" stroke-width="1.5" fill="none"/>
<line x1="1" y1="6" x2="15" y2="6" stroke="#CCCCCC" stroke-width="1.5"/>
<circle cx="3" cy="4.5" r="0.5" fill="#CCCCCC"/>
<circle cx="5" cy="4.5" r="0.5" fill="#CCCCCC"/>
<circle cx="7" cy="4.5" r="0.5" fill="#CCCCCC"/>
<rect x="3" y="8" width="10" height="1" rx="0.5" fill="#CCCCCC" opacity="0.5"/>
<rect x="3" y="10" width="7" height="1" rx="0.5" fill="#CCCCCC" opacity="0.5"/>
<rect x="3" y="12" width="8" height="1" rx="0.5" fill="#CCCCCC" opacity="0.5"/>
</svg>

After

Width:  |  Height:  |  Size: 676 B

View file

@ -0,0 +1,10 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="3" width="14" height="11" rx="1" stroke="#424242" stroke-width="1.5" fill="none"/>
<line x1="1" y1="6" x2="15" y2="6" stroke="#424242" stroke-width="1.5"/>
<circle cx="3" cy="4.5" r="0.5" fill="#424242"/>
<circle cx="5" cy="4.5" r="0.5" fill="#424242"/>
<circle cx="7" cy="4.5" r="0.5" fill="#424242"/>
<rect x="3" y="8" width="10" height="1" rx="0.5" fill="#424242" opacity="0.5"/>
<rect x="3" y="10" width="7" height="1" rx="0.5" fill="#424242" opacity="0.5"/>
<rect x="3" y="12" width="8" height="1" rx="0.5" fill="#424242" opacity="0.5"/>
</svg>

After

Width:  |  Height:  |  Size: 676 B

View file

@ -53,6 +53,7 @@ export const commandIds = [
"focusInput",
"acceptInput",
"focusPanel",
"openWebPreview",
] as const
export type CommandId = (typeof commandIds)[number]

View file

@ -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<RegisterCommandOptions, "provider">) => {

View file

@ -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<string, string>
attributes?: Record<string, string>
}
export type WebPreviewProviderEvents = {
elementSelected: [element: WebPreviewElement]
}
export class WebPreviewProvider extends EventEmitter<WebPreviewProviderEvents> 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*/ `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; font-src ${webview.cspSource} data:; style-src ${webview.cspSource} 'unsafe-inline'; img-src ${webview.cspSource} https: http: data:; media-src ${webview.cspSource}; script-src ${webview.cspSource} 'nonce-${nonce}'; frame-src https: http:; connect-src https: http: ws: wss:;">
<link rel="stylesheet" type="text/css" href="${stylesUri}">
<link href="${codiconsUri}" rel="stylesheet" />
<title>Web Preview</title>
</head>
<body>
<div id="root"></div>
<script nonce="${nonce}" type="module" src="${scriptUri}"></script>
</body>
</html>
`
}
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}`)
}
}

View file

@ -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("<html><body>Test</body></html>"),
}))
// 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("<!DOCTYPE html>")
expect(mockWebview.html).toContain('<div id="root"></div>')
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: "<div>Test</div>",
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: "<button>Click me</button>",
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("<!DOCTYPE html>")
expect(html).toContain('<div id="root"></div>')
expect(html).toContain("webPreview.js")
expect(html).toContain("test-nonce-12345")
expect(html).toContain("Content-Security-Policy")
expect(html).toContain("frame-src https: http:")
})
})
})

View file

@ -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
}
}

View file

@ -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": [

View file

@ -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<

View file

@ -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({

View file

@ -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;
}

View file

@ -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<Device>(DEVICES[0])
const [isSelecting, setIsSelecting] = useState(false)
const [scale, setScale] = useState(1)
const iframeRef = useRef<HTMLIFrameElement>(null)
const containerRef = useRef<HTMLDivElement>(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 (
<div className="web-preview-container">
<div className="web-preview-header">
<div className="url-bar">
<VSCodeTextField
value={url}
onChange={(e: any) => setUrl(e.target.value)}
onKeyPress={(e: any) => e.key === "Enter" && handleNavigate()}
placeholder="Enter URL..."
className="url-input"
/>
<VSCodeButton onClick={handleNavigate}>Go</VSCodeButton>
</div>
<div className="controls">
<VSCodeDropdown value={selectedDevice.name} onChange={handleDeviceChange}>
{DEVICES.map((device) => (
<VSCodeOption key={device.name} value={device.name}>
{device.name} ({device.width}x{device.height})
</VSCodeOption>
))}
</VSCodeDropdown>
<VSCodeButton appearance={isSelecting ? "primary" : "secondary"} onClick={toggleElementSelection}>
{isSelecting ? "Cancel Selection" : "Select Element"}
</VSCodeButton>
</div>
</div>
<div className="web-preview-content" ref={containerRef}>
<div
className="device-frame"
style={{
width: selectedDevice.width,
height: selectedDevice.height,
transform: `scale(${scale})`,
transformOrigin: "top left",
}}>
<iframe
ref={iframeRef}
src={url}
style={{
width: "100%",
height: "100%",
border: "none",
}}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
/>
</div>
</div>
</div>
)
}
export default WebPreviewView

View file

@ -0,0 +1,242 @@
// npx vitest src/components/webpreview/__tests__/WebPreviewView.spec.tsx
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import { render, screen, fireEvent, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
// Mock vscode API before importing component
vi.mock("../../../utils/vscode", () => ({
vscode: {
postMessage: vi.fn(),
},
}))
// Import component after mocking
import { WebPreviewView } from "../WebPreviewView"
import { vscode } from "../../../utils/vscode"
describe("WebPreviewView", () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.clearAllMocks()
})
it("should render with default URL", () => {
render(<WebPreviewView />)
const urlInput = screen.getByPlaceholderText("Enter URL...") as HTMLInputElement
expect(urlInput.value).toBe("http://localhost:3000")
})
it("should render device dropdown with options", () => {
render(<WebPreviewView />)
const dropdown = screen.getByRole("combobox")
expect(dropdown).toBeInTheDocument()
// Check default selection
expect(dropdown).toHaveTextContent("Desktop")
})
it("should render element selection button", () => {
render(<WebPreviewView />)
const selectButton = screen.getByText("Select Element")
expect(selectButton).toBeInTheDocument()
})
it("should navigate when Go button is clicked", async () => {
render(<WebPreviewView />)
const urlInput = screen.getByPlaceholderText("Enter URL...") as HTMLInputElement
const goButton = screen.getByText("Go")
// Change URL
await userEvent.clear(urlInput)
await userEvent.type(urlInput, "https://example.com")
// Click Go
fireEvent.click(goButton)
// Should post navigation message
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "webPreviewNavigate",
url: "https://example.com",
})
})
it("should navigate when Enter is pressed in URL input", async () => {
render(<WebPreviewView />)
const urlInput = screen.getByPlaceholderText("Enter URL...") as HTMLInputElement
// Change URL and press Enter
await userEvent.clear(urlInput)
await userEvent.type(urlInput, "https://example.com{enter}")
// Should post navigation message
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "webPreviewNavigate",
url: "https://example.com",
})
})
it("should toggle element selection mode", () => {
render(<WebPreviewView />)
const selectButton = screen.getByText("Select Element")
// Click to enable selection
fireEvent.click(selectButton)
// Button should change to cancel mode
expect(screen.getByText("Cancel Selection")).toBeInTheDocument()
// Click again to disable
fireEvent.click(screen.getByText("Cancel Selection"))
// Should be back to normal
expect(screen.getByText("Select Element")).toBeInTheDocument()
})
it("should handle device change", async () => {
render(<WebPreviewView />)
const dropdown = screen.getByRole("combobox")
// Change device
fireEvent.change(dropdown, { target: { value: "iPhone 14" } })
// Check if device frame is updated
await waitFor(() => {
const deviceFrame = document.querySelector(".device-frame") as HTMLElement
expect(deviceFrame.style.width).toBe("390px")
expect(deviceFrame.style.height).toBe("844px")
})
})
it("should post ready message on mount", () => {
render(<WebPreviewView />)
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "webPreviewReady",
})
})
it("should handle navigation message from extension", () => {
render(<WebPreviewView />)
const urlInput = screen.getByPlaceholderText("Enter URL...") as HTMLInputElement
// Simulate message from extension
const event = new MessageEvent("message", {
data: {
type: "webPreviewNavigate",
url: "https://new-url.com",
},
})
window.dispatchEvent(event)
// URL should be updated
expect(urlInput.value).toBe("https://new-url.com")
})
it("should handle device change message from extension", async () => {
render(<WebPreviewView />)
// Simulate message from extension
const event = new MessageEvent("message", {
data: {
type: "webPreviewSetDevice",
device: "iPad",
},
})
window.dispatchEvent(event)
// Device should be updated
await waitFor(() => {
const deviceFrame = document.querySelector(".device-frame") as HTMLElement
expect(deviceFrame.style.width).toBe("768px")
expect(deviceFrame.style.height).toBe("1024px")
})
})
it("should handle element selection from iframe", () => {
render(<WebPreviewView />)
// Enable selection mode
const selectButton = screen.getByText("Select Element")
fireEvent.click(selectButton)
// Simulate element selection message from iframe
const event = new MessageEvent("message", {
data: {
type: "elementSelected",
element: {
html: "<button>Test</button>",
selector: "button",
xpath: "/html/body/button",
position: { x: 10, y: 20, width: 100, height: 40 },
},
},
})
window.dispatchEvent(event)
// Should post element to extension
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "webPreviewElementSelected",
element: expect.objectContaining({
html: "<button>Test</button>",
selector: "button",
}),
})
// Selection mode should be disabled
expect(screen.getByText("Select Element")).toBeInTheDocument()
})
it("should scale device frame to fit container", async () => {
// Mock container dimensions
Object.defineProperty(HTMLElement.prototype, "clientWidth", {
configurable: true,
value: 800,
})
Object.defineProperty(HTMLElement.prototype, "clientHeight", {
configurable: true,
value: 600,
})
render(<WebPreviewView />)
// Select a large device
const dropdown = screen.getByRole("combobox")
fireEvent.change(dropdown, { target: { value: "Desktop" } })
// Device frame should be scaled down
await waitFor(() => {
const deviceFrame = document.querySelector(".device-frame") as HTMLElement
const transform = deviceFrame.style.transform
expect(transform).toContain("scale(")
// Scale should be less than 1 for desktop on small container
expect(parseFloat(transform.match(/scale\(([\d.]+)\)/)?.[1] || "1")).toBeLessThan(1)
})
})
it("should render iframe with correct attributes", () => {
render(<WebPreviewView />)
const iframe = document.querySelector("iframe") as HTMLIFrameElement
expect(iframe).toHaveAttribute("src", "http://localhost:3000")
expect(iframe).toHaveAttribute("sandbox", "allow-scripts allow-same-origin allow-forms allow-popups")
expect(iframe.style.width).toBe("100%")
expect(iframe.style.height).toBe("100%")
expect(iframe.style.border).toBe("none")
})
})

View file

@ -0,0 +1,11 @@
import React from "react"
import ReactDOM from "react-dom/client"
import WebPreviewView from "./components/webpreview/WebPreviewView"
import "./index.css"
const root = ReactDOM.createRoot(document.getElementById("root") as HTMLElement)
root.render(
<React.StrictMode>
<WebPreviewView />
</React.StrictMode>,
)

View file

@ -96,6 +96,10 @@ export default defineConfig(({ mode }) => {
reportCompressedSize: false,
sourcemap: true,
rollupOptions: {
input: {
index: resolve(__dirname, "index.html"),
webPreview: resolve(__dirname, "webPreview.html"),
},
output: {
entryFileNames: `assets/[name].js`,
chunkFileNames: (chunkInfo) => {

View file

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Web Preview</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/webPreview.tsx"></script>
</body>
</html>