mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
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:
parent
de13d8a5cf
commit
fd0ed5ae12
17 changed files with 1446 additions and 0 deletions
18
README.md
18
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:
|
||||
|
|
|
|||
10
assets/icons/browser_dark.svg
Normal file
10
assets/icons/browser_dark.svg
Normal 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 |
10
assets/icons/browser_light.svg
Normal file
10
assets/icons/browser_light.svg
Normal 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 |
|
|
@ -53,6 +53,7 @@ export const commandIds = [
|
|||
"focusInput",
|
||||
"acceptInput",
|
||||
"focusPanel",
|
||||
"openWebPreview",
|
||||
] as const
|
||||
|
||||
export type CommandId = (typeof commandIds)[number]
|
||||
|
|
|
|||
|
|
@ -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">) => {
|
||||
|
|
|
|||
274
src/core/webview/WebPreviewProvider.ts
Normal file
274
src/core/webview/WebPreviewProvider.ts
Normal 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}`)
|
||||
}
|
||||
}
|
||||
319
src/core/webview/__tests__/WebPreviewProvider.spec.ts
Normal file
319
src/core/webview/__tests__/WebPreviewProvider.spec.ts
Normal 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:")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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<
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
140
webview-ui/src/components/webpreview/WebPreviewView.css
Normal file
140
webview-ui/src/components/webpreview/WebPreviewView.css
Normal 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;
|
||||
}
|
||||
327
webview-ui/src/components/webpreview/WebPreviewView.tsx
Normal file
327
webview-ui/src/components/webpreview/WebPreviewView.tsx
Normal 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
|
||||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
11
webview-ui/src/webPreview.tsx
Normal file
11
webview-ui/src/webPreview.tsx
Normal 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>,
|
||||
)
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
12
webview-ui/webPreview.html
Normal file
12
webview-ui/webPreview.html
Normal 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>
|
||||
Loading…
Add table
Reference in a new issue