mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-10-09 03:17:58 +00:00
feat: add integrated web preview with element selection (fixes #5971)
- Add WebPreviewProvider to manage preview panel in sidebar - Create React-based UI with URL navigation and viewport controls - Implement element selection with context extraction (HTML, CSS, XPath) - Add web_preview tool for AI integration - Register commands and update package.json - Update type definitions and tool configurations This implementation provides: - Web preview panel in VSCode sidebar - Click-to-select elements with visual highlighting - Element context extraction for AI assistance - Responsive viewport controls - Integration with Roo Code AI through web_preview tool
This commit is contained in:
parent
de13d8a5cf
commit
ab7ccf1852
16 changed files with 1026 additions and 0 deletions
|
|
@ -34,6 +34,7 @@ export const toolNames = [
|
|||
"fetch_instructions",
|
||||
"codebase_search",
|
||||
"update_todo_list",
|
||||
"web_preview",
|
||||
] as const
|
||||
|
||||
export const toolNamesSchema = z.enum(toolNames)
|
||||
|
|
|
|||
|
|
@ -53,6 +53,8 @@ export const commandIds = [
|
|||
"focusInput",
|
||||
"acceptInput",
|
||||
"focusPanel",
|
||||
"openWebPreview",
|
||||
"getSelectedElement",
|
||||
] as const
|
||||
|
||||
export type CommandId = (typeof commandIds)[number]
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { TelemetryService } from "@roo-code/telemetry"
|
|||
import { Package } from "../shared/package"
|
||||
import { getCommand } from "../utils/commands"
|
||||
import { ClineProvider } from "../core/webview/ClineProvider"
|
||||
import { WebPreviewProvider } from "../core/webview/WebPreviewProvider"
|
||||
import { ContextProxy } from "../core/config/ContextProxy"
|
||||
import { focusPanel } from "../utils/focusPanel"
|
||||
|
||||
|
|
@ -32,6 +33,7 @@ export function getVisibleProviderOrLog(outputChannel: vscode.OutputChannel): Cl
|
|||
// Store panel references in both modes
|
||||
let sidebarPanel: vscode.WebviewView | undefined = undefined
|
||||
let tabPanel: vscode.WebviewPanel | undefined = undefined
|
||||
let webPreviewProvider: WebPreviewProvider | undefined = undefined
|
||||
|
||||
/**
|
||||
* Get the currently active panel
|
||||
|
|
@ -57,6 +59,10 @@ export function setPanel(
|
|||
}
|
||||
}
|
||||
|
||||
export function setWebPreviewProvider(provider: WebPreviewProvider) {
|
||||
webPreviewProvider = provider
|
||||
}
|
||||
|
||||
export type RegisterCommandOptions = {
|
||||
context: vscode.ExtensionContext
|
||||
outputChannel: vscode.OutputChannel
|
||||
|
|
@ -218,6 +224,27 @@ const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOpt
|
|||
|
||||
visibleProvider.postMessageToWebview({ type: "acceptInput" })
|
||||
},
|
||||
openWebPreview: async (url?: string) => {
|
||||
if (!webPreviewProvider) {
|
||||
outputChannel.appendLine("Web Preview Provider not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
if (url) {
|
||||
await webPreviewProvider.openUrl(url)
|
||||
} else {
|
||||
// Show the web preview panel without navigating
|
||||
await vscode.commands.executeCommand("roo-code.webPreview.focus")
|
||||
}
|
||||
},
|
||||
getSelectedElement: async () => {
|
||||
if (!webPreviewProvider) {
|
||||
outputChannel.appendLine("Web Preview Provider not initialized")
|
||||
return null
|
||||
}
|
||||
|
||||
return webPreviewProvider.getSelectedElementContext()
|
||||
},
|
||||
})
|
||||
|
||||
export const openClineInNewTab = async ({ context, outputChannel }: Omit<RegisterCommandOptions, "provider">) => {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { askFollowupQuestionTool } from "../tools/askFollowupQuestionTool"
|
|||
import { switchModeTool } from "../tools/switchModeTool"
|
||||
import { attemptCompletionTool } from "../tools/attemptCompletionTool"
|
||||
import { newTaskTool } from "../tools/newTaskTool"
|
||||
import { webPreviewTool } from "../tools/webPreviewTool"
|
||||
|
||||
import { checkpointSave } from "../checkpoints"
|
||||
import { updateTodoListTool } from "../tools/updateTodoListTool"
|
||||
|
|
@ -214,6 +215,8 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
const modeName = getModeBySlug(mode, customModes)?.name ?? mode
|
||||
return `[${block.name} in ${modeName} mode: '${message}']`
|
||||
}
|
||||
case "web_preview":
|
||||
return `[${block.name} for '${block.params.action}'${block.params.url ? ` - ${block.params.url}` : ""}]`
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -522,6 +525,9 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
askFinishSubTaskApproval,
|
||||
)
|
||||
break
|
||||
case "web_preview":
|
||||
await webPreviewTool(cline, block, askApproval, handleError, pushToolResult)
|
||||
break
|
||||
}
|
||||
|
||||
break
|
||||
|
|
|
|||
91
src/core/tools/webPreviewTool.ts
Normal file
91
src/core/tools/webPreviewTool.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import * as vscode from "vscode"
|
||||
import { Task } from "../task/Task"
|
||||
import { ClineSayTool } from "../../shared/ExtensionMessage"
|
||||
import { formatResponse } from "../prompts/responses"
|
||||
import { ToolUse, AskApproval, HandleError, PushToolResult } from "../../shared/tools"
|
||||
|
||||
export async function webPreviewTool(
|
||||
cline: Task,
|
||||
block: ToolUse,
|
||||
askApproval: AskApproval,
|
||||
handleError: HandleError,
|
||||
pushToolResult: PushToolResult,
|
||||
) {
|
||||
const action = block.params.action
|
||||
const url = block.params.url
|
||||
|
||||
if (!action) {
|
||||
cline.consecutiveMistakeCount++
|
||||
const errorMsg = await cline.sayAndCreateMissingParamError("web_preview", "action")
|
||||
pushToolResult(formatResponse.toolError(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
if (action === "open" && !url) {
|
||||
cline.consecutiveMistakeCount++
|
||||
const errorMsg = await cline.sayAndCreateMissingParamError("web_preview", "url")
|
||||
pushToolResult(formatResponse.toolError(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Handle partial message
|
||||
if (block.partial) {
|
||||
const partialMessage = JSON.stringify({
|
||||
tool: "web_preview",
|
||||
action,
|
||||
url,
|
||||
} satisfies ClineSayTool)
|
||||
await cline.ask("tool", partialMessage, block.partial).catch(() => {})
|
||||
return
|
||||
}
|
||||
|
||||
// Ask for approval
|
||||
const completeMessage = JSON.stringify({
|
||||
tool: "web_preview",
|
||||
action,
|
||||
url,
|
||||
} satisfies ClineSayTool)
|
||||
|
||||
const { response, text, images } = await cline.ask("tool", completeMessage, false)
|
||||
|
||||
if (response !== "yesButtonClicked") {
|
||||
if (text) {
|
||||
await cline.say("user_feedback", text, images)
|
||||
}
|
||||
cline.didRejectTool = true
|
||||
pushToolResult(formatResponse.toolDenied())
|
||||
return
|
||||
}
|
||||
|
||||
if (text) {
|
||||
await cline.say("user_feedback", text, images)
|
||||
}
|
||||
|
||||
// Execute the web preview action
|
||||
if (action === "open") {
|
||||
// Open the web preview panel with the URL
|
||||
await vscode.commands.executeCommand("roo-code.openWebPreview", url)
|
||||
pushToolResult(formatResponse.toolResult(`Opened web preview for: ${url}`))
|
||||
} else if (action === "select") {
|
||||
// Get the current selected element context from the preview
|
||||
const selectedContext = await vscode.commands.executeCommand("roo-code.getSelectedElement")
|
||||
if (selectedContext) {
|
||||
pushToolResult(
|
||||
formatResponse.toolResult(`Selected element context:\n${JSON.stringify(selectedContext, null, 2)}`),
|
||||
)
|
||||
} else {
|
||||
pushToolResult(
|
||||
formatResponse.toolResult(
|
||||
"No element is currently selected. Click on an element in the preview to select it.",
|
||||
),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
pushToolResult(formatResponse.toolError(`Unknown action: ${action}. Valid actions are: open, select`))
|
||||
}
|
||||
} catch (error) {
|
||||
await handleError("web preview operation", error instanceof Error ? error : new Error(String(error)))
|
||||
pushToolResult(formatResponse.toolError(error instanceof Error ? error.message : String(error)))
|
||||
}
|
||||
}
|
||||
235
src/core/webview/WebPreviewProvider.ts
Normal file
235
src/core/webview/WebPreviewProvider.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
import * as vscode from "vscode"
|
||||
import * as path from "path"
|
||||
import { getNonce } from "./getNonce"
|
||||
import { getUri } from "./getUri"
|
||||
import { ClineProvider } from "./ClineProvider"
|
||||
import { ExtensionMessage } from "../../shared/ExtensionMessage"
|
||||
import { WebviewMessage } from "../../shared/WebviewMessage"
|
||||
|
||||
export interface ElementContext {
|
||||
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 interface PreviewState {
|
||||
url?: string
|
||||
isLoading: boolean
|
||||
selectedElement?: ElementContext
|
||||
viewportSize: { width: number; height: number }
|
||||
deviceMode: "desktop" | "tablet" | "mobile" | "custom"
|
||||
}
|
||||
|
||||
export class WebPreviewProvider implements vscode.WebviewViewProvider {
|
||||
public static readonly viewId = "roo-code.webPreview"
|
||||
private _view?: vscode.WebviewView
|
||||
private _extensionUri: vscode.Uri
|
||||
private _clineProvider?: ClineProvider
|
||||
private _state: PreviewState = {
|
||||
isLoading: false,
|
||||
viewportSize: { width: 1200, height: 800 },
|
||||
deviceMode: "desktop",
|
||||
}
|
||||
|
||||
// Common device presets
|
||||
private readonly devicePresets = {
|
||||
desktop: { width: 1200, height: 800, name: "Desktop" },
|
||||
tablet: { width: 768, height: 1024, name: "Tablet" },
|
||||
mobile: { width: 375, height: 667, name: "Mobile" },
|
||||
}
|
||||
|
||||
constructor(private readonly _context: vscode.ExtensionContext) {
|
||||
this._extensionUri = _context.extensionUri
|
||||
}
|
||||
|
||||
public setClineProvider(provider: ClineProvider) {
|
||||
this._clineProvider = provider
|
||||
}
|
||||
|
||||
public resolveWebviewView(
|
||||
webviewView: vscode.WebviewView,
|
||||
context: vscode.WebviewViewResolveContext,
|
||||
_token: vscode.CancellationToken,
|
||||
) {
|
||||
this._view = webviewView
|
||||
|
||||
webviewView.webview.options = {
|
||||
enableScripts: true,
|
||||
localResourceRoots: [this._extensionUri],
|
||||
}
|
||||
|
||||
webviewView.webview.html = this._getHtmlForWebview(webviewView.webview)
|
||||
|
||||
// Handle messages from the webview
|
||||
webviewView.webview.onDidReceiveMessage(async (message: WebviewMessage) => {
|
||||
switch (message.type) {
|
||||
case "elementSelected":
|
||||
await this._handleElementSelection(message.elementContext)
|
||||
break
|
||||
case "navigateToUrl":
|
||||
await this._handleNavigation(message.url!)
|
||||
break
|
||||
case "setViewportSize":
|
||||
this._handleViewportResize(message.width!, message.height!)
|
||||
break
|
||||
case "setDeviceMode":
|
||||
this._handleDeviceModeChange(message.deviceMode as PreviewState["deviceMode"])
|
||||
break
|
||||
case "refreshPreview":
|
||||
await this._refreshPreview()
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
// Handle visibility changes
|
||||
webviewView.onDidChangeVisibility(() => {
|
||||
if (webviewView.visible) {
|
||||
this._updateWebview()
|
||||
}
|
||||
})
|
||||
|
||||
// Handle disposal
|
||||
webviewView.onDidDispose(() => {
|
||||
this._view = undefined
|
||||
})
|
||||
}
|
||||
|
||||
private async _handleElementSelection(elementContext: ElementContext) {
|
||||
this._state.selectedElement = elementContext
|
||||
|
||||
// Send element context to Cline
|
||||
if (this._clineProvider) {
|
||||
const contextMessage = this._formatElementContext(elementContext)
|
||||
await this._clineProvider.postMessageToWebview({
|
||||
type: "webPreviewElementSelected",
|
||||
elementContext: contextMessage,
|
||||
})
|
||||
}
|
||||
|
||||
this._updateWebview()
|
||||
}
|
||||
|
||||
private _formatElementContext(context: ElementContext): string {
|
||||
const lines = [
|
||||
"Selected Element Context:",
|
||||
`- HTML: ${context.html}`,
|
||||
`- CSS Selector: ${context.selector}`,
|
||||
`- XPath: ${context.xpath}`,
|
||||
`- Position: ${context.position.x}x${context.position.y} (${context.position.width}x${context.position.height})`,
|
||||
]
|
||||
|
||||
if (context.attributes && Object.keys(context.attributes).length > 0) {
|
||||
lines.push(`- Attributes: ${JSON.stringify(context.attributes, null, 2)}`)
|
||||
}
|
||||
|
||||
if (context.computedStyles && Object.keys(context.computedStyles).length > 0) {
|
||||
const importantStyles = ["display", "position", "width", "height", "color", "background-color", "font-size"]
|
||||
const styles = importantStyles
|
||||
.filter((style) => context.computedStyles![style])
|
||||
.map((style) => `${style}: ${context.computedStyles![style]}`)
|
||||
if (styles.length > 0) {
|
||||
lines.push(`- Key Styles: ${styles.join(", ")}`)
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
private async _handleNavigation(url: string) {
|
||||
this._state.url = url
|
||||
this._state.isLoading = true
|
||||
this._updateWebview()
|
||||
|
||||
// Simulate loading completion after a delay
|
||||
setTimeout(() => {
|
||||
this._state.isLoading = false
|
||||
this._updateWebview()
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
private _handleViewportResize(width: number, height: number) {
|
||||
this._state.viewportSize = { width, height }
|
||||
this._state.deviceMode = "custom"
|
||||
this._updateWebview()
|
||||
}
|
||||
|
||||
private _handleDeviceModeChange(mode: PreviewState["deviceMode"]) {
|
||||
this._state.deviceMode = mode
|
||||
if (mode !== "custom" && this.devicePresets[mode]) {
|
||||
const preset = this.devicePresets[mode]
|
||||
this._state.viewportSize = { width: preset.width, height: preset.height }
|
||||
}
|
||||
this._updateWebview()
|
||||
}
|
||||
|
||||
private async _refreshPreview() {
|
||||
if (this._state.url) {
|
||||
await this._handleNavigation(this._state.url)
|
||||
}
|
||||
}
|
||||
|
||||
private _updateWebview() {
|
||||
if (this._view) {
|
||||
this._view.webview.postMessage({
|
||||
type: "updateState",
|
||||
state: this._state,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
public async openUrl(url: string) {
|
||||
this._state.url = url
|
||||
await this._handleNavigation(url)
|
||||
|
||||
// Show the preview panel
|
||||
if (this._view) {
|
||||
this._view.show(true)
|
||||
}
|
||||
}
|
||||
|
||||
public getSelectedElementContext(): ElementContext | undefined {
|
||||
return this._state.selectedElement
|
||||
}
|
||||
|
||||
private _getHtmlForWebview(webview: vscode.Webview): string {
|
||||
const scriptUri = getUri(webview, this._extensionUri, ["webview-ui", "build", "assets", "webPreview.js"])
|
||||
const styleUri = getUri(webview, this._extensionUri, ["webview-ui", "build", "assets", "index.css"])
|
||||
const codiconsUri = getUri(webview, this._extensionUri, ["assets", "codicons", "codicon.css"])
|
||||
const nonce = getNonce()
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none';
|
||||
style-src ${webview.cspSource} 'unsafe-inline';
|
||||
script-src 'nonce-${nonce}';
|
||||
font-src ${webview.cspSource};
|
||||
frame-src http: https: file:;
|
||||
img-src ${webview.cspSource} https: http: data:;
|
||||
connect-src http: https: ws: wss:;">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link href="${styleUri}" rel="stylesheet">
|
||||
<link href="${codiconsUri}" rel="stylesheet">
|
||||
<title>Web Preview</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script nonce="${nonce}">
|
||||
const vscode = acquireVsCodeApi();
|
||||
window.vscode = vscode;
|
||||
</script>
|
||||
<script nonce="${nonce}" src="${scriptUri}"></script>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ import { Package } from "./shared/package"
|
|||
import { formatLanguage } from "./shared/language"
|
||||
import { ContextProxy } from "./core/config/ContextProxy"
|
||||
import { ClineProvider } from "./core/webview/ClineProvider"
|
||||
import { WebPreviewProvider } from "./core/webview/WebPreviewProvider"
|
||||
import { DIFF_VIEW_URI_SCHEME } from "./integrations/editor/DiffViewProvider"
|
||||
import { TerminalRegistry } from "./integrations/terminal/TerminalRegistry"
|
||||
import { McpServerManager } from "./services/mcp/McpServerManager"
|
||||
|
|
@ -121,6 +122,15 @@ export async function activate(context: vscode.ExtensionContext) {
|
|||
}),
|
||||
)
|
||||
|
||||
// Register the Web Preview Provider
|
||||
const webPreviewProvider = new WebPreviewProvider(context)
|
||||
webPreviewProvider.setClineProvider(provider)
|
||||
context.subscriptions.push(
|
||||
vscode.window.registerWebviewViewProvider(WebPreviewProvider.viewId, webPreviewProvider, {
|
||||
webviewOptions: { retainContextWhenHidden: true },
|
||||
}),
|
||||
)
|
||||
|
||||
// Auto-import configuration if specified in settings
|
||||
try {
|
||||
await autoImportSettings(outputChannel, {
|
||||
|
|
@ -136,6 +146,10 @@ export async function activate(context: vscode.ExtensionContext) {
|
|||
|
||||
registerCommands({ context, outputChannel, provider })
|
||||
|
||||
// Set the web preview provider in registerCommands
|
||||
const { setWebPreviewProvider } = await import("./activate/registerCommands")
|
||||
setWebPreviewProvider(webPreviewProvider)
|
||||
|
||||
/**
|
||||
* We use the text document content provider API to show the left side for diff
|
||||
* view by creating a virtual document for the original content. This makes it
|
||||
|
|
|
|||
|
|
@ -66,6 +66,12 @@
|
|||
"type": "webview",
|
||||
"id": "roo-cline.SidebarProvider",
|
||||
"name": "%views.sidebar.name%"
|
||||
},
|
||||
{
|
||||
"type": "webview",
|
||||
"id": "roo-code.webPreview",
|
||||
"name": "Web Preview",
|
||||
"visibility": "collapsed"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
@ -174,6 +180,16 @@
|
|||
"command": "roo-cline.acceptInput",
|
||||
"title": "%command.acceptInput.title%",
|
||||
"category": "%configuration.title%"
|
||||
},
|
||||
{
|
||||
"command": "roo-cline.openWebPreview",
|
||||
"title": "Open Web Preview",
|
||||
"category": "%configuration.title%"
|
||||
},
|
||||
{
|
||||
"command": "roo-cline.getSelectedElement",
|
||||
"title": "Get Selected Element",
|
||||
"category": "%configuration.title%"
|
||||
}
|
||||
],
|
||||
"menus": {
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ export interface ExtensionMessage {
|
|||
| "codeIndexSecretStatus"
|
||||
| "showDeleteMessageDialog"
|
||||
| "showEditMessageDialog"
|
||||
| "webPreviewElementSelected"
|
||||
text?: string
|
||||
payload?: any // Add a generic payload for now, can refine later
|
||||
action?:
|
||||
|
|
@ -161,6 +162,7 @@ export interface ExtensionMessage {
|
|||
settings?: any
|
||||
messageTs?: number
|
||||
context?: string
|
||||
elementContext?: string
|
||||
}
|
||||
|
||||
export type ExtensionState = Pick<
|
||||
|
|
@ -302,6 +304,7 @@ export interface ClineSayTool {
|
|||
| "finishTask"
|
||||
| "searchAndReplace"
|
||||
| "insertContent"
|
||||
| "web_preview"
|
||||
path?: string
|
||||
diff?: string
|
||||
content?: string
|
||||
|
|
@ -338,6 +341,8 @@ export interface ClineSayTool {
|
|||
}>
|
||||
}>
|
||||
question?: string
|
||||
action?: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
// Must keep in sync with system prompt.
|
||||
|
|
|
|||
|
|
@ -198,6 +198,11 @@ export interface WebviewMessage {
|
|||
| "checkRulesDirectoryResult"
|
||||
| "saveCodeIndexSettingsAtomic"
|
||||
| "requestCodeIndexSecretStatus"
|
||||
| "elementSelected"
|
||||
| "navigateToUrl"
|
||||
| "setViewportSize"
|
||||
| "setDeviceMode"
|
||||
| "refreshPreview"
|
||||
text?: string
|
||||
editedMessageContent?: string
|
||||
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "account"
|
||||
|
|
@ -260,6 +265,11 @@ export interface WebviewMessage {
|
|||
codebaseIndexGeminiApiKey?: string
|
||||
codebaseIndexMistralApiKey?: string
|
||||
}
|
||||
// Web preview specific fields
|
||||
elementContext?: any
|
||||
width?: number
|
||||
height?: number
|
||||
deviceMode?: string
|
||||
}
|
||||
|
||||
export const checkoutDiffPayloadSchema = z.object({
|
||||
|
|
|
|||
|
|
@ -190,6 +190,7 @@ export const TOOL_DISPLAY_NAMES: Record<ToolName, string> = {
|
|||
search_and_replace: "search and replace",
|
||||
codebase_search: "codebase search",
|
||||
update_todo_list: "update todo list",
|
||||
web_preview: "web preview",
|
||||
} as const
|
||||
|
||||
// Define available tool groups.
|
||||
|
|
|
|||
180
webview-ui/src/components/web-preview/WebPreview.css
Normal file
180
webview-ui/src/components/web-preview/WebPreview.css
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
.web-preview-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background-color: var(--vscode-editor-background);
|
||||
color: var(--vscode-editor-foreground);
|
||||
}
|
||||
|
||||
.web-preview-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 8px 16px;
|
||||
background-color: var(--vscode-editorWidget-background);
|
||||
border-bottom: 1px solid var(--vscode-widget-border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.url-input {
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
padding: 4px 8px;
|
||||
background-color: var(--vscode-input-background);
|
||||
color: var(--vscode-input-foreground);
|
||||
border: 1px solid var(--vscode-input-border);
|
||||
border-radius: 2px;
|
||||
font-family: var(--vscode-font-family);
|
||||
font-size: var(--vscode-font-size);
|
||||
}
|
||||
|
||||
.url-input:focus {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.custom-size-inputs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.size-input {
|
||||
width: 80px;
|
||||
padding: 4px 8px;
|
||||
background-color: var(--vscode-input-background);
|
||||
color: var(--vscode-input-foreground);
|
||||
border: 1px solid var(--vscode-input-border);
|
||||
border-radius: 2px;
|
||||
font-family: var(--vscode-font-family);
|
||||
font-size: var(--vscode-font-size);
|
||||
}
|
||||
|
||||
.size-input:focus {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.web-preview-content {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--vscode-editor-background);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
color: var(--vscode-editor-foreground);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.preview-iframe {
|
||||
background-color: white;
|
||||
border: 1px solid var(--vscode-widget-border);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
transition:
|
||||
width 0.3s ease,
|
||||
height 0.3s ease;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.empty-state .codicon {
|
||||
font-size: 48px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.element-info {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
background-color: var(--vscode-editorWidget-background);
|
||||
border: 1px solid var(--vscode-widget-border);
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.element-info h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--vscode-editor-foreground);
|
||||
}
|
||||
|
||||
.info-item {
|
||||
margin: 4px 0;
|
||||
font-size: 12px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.info-item strong {
|
||||
color: var(--vscode-editor-foreground);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.info-item code {
|
||||
background-color: var(--vscode-textCodeBlock-background);
|
||||
padding: 2px 4px;
|
||||
border-radius: 2px;
|
||||
font-family: var(--vscode-editor-font-family);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.web-preview-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar-section {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.url-input {
|
||||
min-width: unset;
|
||||
}
|
||||
}
|
||||
|
||||
/* Animation for loading spinner */
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.codicon-loading.codicon-modifier-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
411
webview-ui/src/components/web-preview/WebPreview.tsx
Normal file
411
webview-ui/src/components/web-preview/WebPreview.tsx
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
import React, { useEffect, useRef, useState, useCallback } from "react"
|
||||
import { VSCodeButton, VSCodeDropdown, VSCodeOption } from "@vscode/webview-ui-toolkit/react"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
import "./WebPreview.css"
|
||||
|
||||
interface ElementContext {
|
||||
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>
|
||||
}
|
||||
|
||||
interface PreviewState {
|
||||
url?: string
|
||||
isLoading: boolean
|
||||
selectedElement?: ElementContext
|
||||
viewportSize: { width: number; height: number }
|
||||
deviceMode: "desktop" | "tablet" | "mobile" | "custom"
|
||||
}
|
||||
|
||||
const WebPreview: React.FC = () => {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||
const [state, setState] = useState<PreviewState>({
|
||||
isLoading: false,
|
||||
viewportSize: { width: 1200, height: 800 },
|
||||
deviceMode: "desktop",
|
||||
})
|
||||
const [urlInput, setUrlInput] = useState("")
|
||||
const [isSelectionMode, setIsSelectionMode] = useState(false)
|
||||
const [highlightedElement, setHighlightedElement] = useState<HTMLElement | null>(null)
|
||||
|
||||
// Handle messages from extension
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
const message = event.data
|
||||
switch (message.type) {
|
||||
case "updateState":
|
||||
setState(message.state)
|
||||
if (message.state.url) {
|
||||
setUrlInput(message.state.url)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", handleMessage)
|
||||
return () => window.removeEventListener("message", handleMessage)
|
||||
}, [])
|
||||
|
||||
// Navigate to URL
|
||||
const handleNavigate = useCallback(() => {
|
||||
if (urlInput) {
|
||||
vscode.postMessage({ type: "navigateToUrl", url: urlInput })
|
||||
}
|
||||
}, [urlInput])
|
||||
|
||||
// Handle device mode change
|
||||
const handleDeviceModeChange = useCallback((e: any) => {
|
||||
const mode = e.target.value as PreviewState["deviceMode"]
|
||||
vscode.postMessage({ type: "setDeviceMode", deviceMode: mode })
|
||||
}, [])
|
||||
|
||||
// Handle viewport size change
|
||||
const handleViewportResize = useCallback((width: number, height: number) => {
|
||||
vscode.postMessage({ type: "setViewportSize", width, height })
|
||||
}, [])
|
||||
|
||||
// Refresh preview
|
||||
const handleRefresh = useCallback(() => {
|
||||
vscode.postMessage({ type: "refreshPreview" })
|
||||
}, [])
|
||||
|
||||
// Extract element context
|
||||
const extractElementContext = useCallback((element: HTMLElement): ElementContext => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
const computedStyles = window.getComputedStyle(element)
|
||||
|
||||
// Get important computed styles
|
||||
const importantStyles: Record<string, string> = {}
|
||||
const stylesToCapture = [
|
||||
"display",
|
||||
"position",
|
||||
"width",
|
||||
"height",
|
||||
"margin",
|
||||
"padding",
|
||||
"color",
|
||||
"background-color",
|
||||
"font-size",
|
||||
"font-family",
|
||||
"font-weight",
|
||||
"border",
|
||||
"z-index",
|
||||
"opacity",
|
||||
"visibility",
|
||||
]
|
||||
stylesToCapture.forEach((style) => {
|
||||
importantStyles[style] = computedStyles.getPropertyValue(style)
|
||||
})
|
||||
|
||||
// Get attributes
|
||||
const attributes: Record<string, string> = {}
|
||||
Array.from(element.attributes).forEach((attr) => {
|
||||
attributes[attr.name] = attr.value
|
||||
})
|
||||
|
||||
// Generate CSS selector
|
||||
const generateSelector = (el: HTMLElement): string => {
|
||||
const path: string[] = []
|
||||
let current: HTMLElement | null = el
|
||||
|
||||
while (current && current !== document.body) {
|
||||
let selector = current.tagName.toLowerCase()
|
||||
|
||||
if (current.id) {
|
||||
selector = `#${current.id}`
|
||||
path.unshift(selector)
|
||||
break
|
||||
} else if (current.className) {
|
||||
const classes = Array.from(current.classList)
|
||||
.filter((c) => c)
|
||||
.join(".")
|
||||
if (classes) {
|
||||
selector += `.${classes}`
|
||||
}
|
||||
}
|
||||
|
||||
// Add nth-child if needed
|
||||
const parent = current.parentElement
|
||||
if (parent) {
|
||||
const siblings = Array.from(parent.children).filter((child) => child.tagName === current!.tagName)
|
||||
if (siblings.length > 1) {
|
||||
const index = siblings.indexOf(current) + 1
|
||||
selector += `:nth-child(${index})`
|
||||
}
|
||||
}
|
||||
|
||||
path.unshift(selector)
|
||||
current = current.parentElement
|
||||
}
|
||||
|
||||
return path.join(" > ")
|
||||
}
|
||||
|
||||
// Generate XPath
|
||||
const generateXPath = (el: HTMLElement): string => {
|
||||
const path: string[] = []
|
||||
let current: HTMLElement | null = el
|
||||
|
||||
while (current && current !== document.body) {
|
||||
let index = 0
|
||||
let sibling = current.previousSibling
|
||||
|
||||
while (sibling) {
|
||||
if (
|
||||
sibling.nodeType === Node.ELEMENT_NODE &&
|
||||
(sibling as HTMLElement).tagName === current.tagName
|
||||
) {
|
||||
index++
|
||||
}
|
||||
sibling = sibling.previousSibling
|
||||
}
|
||||
|
||||
const tagName = current.tagName.toLowerCase()
|
||||
const xpathIndex = index > 0 ? `[${index + 1}]` : ""
|
||||
path.unshift(`${tagName}${xpathIndex}`)
|
||||
|
||||
current = current.parentElement
|
||||
}
|
||||
|
||||
return `//${path.join("/")}`
|
||||
}
|
||||
|
||||
return {
|
||||
html: element.outerHTML.substring(0, 200) + (element.outerHTML.length > 200 ? "..." : ""),
|
||||
css: generateSelector(element),
|
||||
xpath: generateXPath(element),
|
||||
selector: generateSelector(element),
|
||||
position: {
|
||||
x: rect.left,
|
||||
y: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
},
|
||||
computedStyles: importantStyles,
|
||||
attributes,
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Handle element selection in iframe
|
||||
const setupIframeInteraction = useCallback(() => {
|
||||
const iframe = iframeRef.current
|
||||
if (!iframe || !iframe.contentWindow) return
|
||||
|
||||
try {
|
||||
const iframeDoc = iframe.contentDocument || iframe.contentWindow.document
|
||||
|
||||
// Remove any existing highlight
|
||||
const existingHighlight = iframeDoc.getElementById("roo-preview-highlight")
|
||||
if (existingHighlight) {
|
||||
existingHighlight.remove()
|
||||
}
|
||||
|
||||
// Add styles for highlighting
|
||||
if (!iframeDoc.getElementById("roo-preview-styles")) {
|
||||
const style = iframeDoc.createElement("style")
|
||||
style.id = "roo-preview-styles"
|
||||
style.textContent = `
|
||||
.roo-preview-highlight {
|
||||
outline: 2px solid #007ACC !important;
|
||||
outline-offset: 2px !important;
|
||||
background-color: rgba(0, 122, 204, 0.1) !important;
|
||||
cursor: pointer !important;
|
||||
}
|
||||
.roo-preview-selection-mode * {
|
||||
cursor: crosshair !important;
|
||||
}
|
||||
`
|
||||
iframeDoc.head.appendChild(style)
|
||||
}
|
||||
|
||||
// Toggle selection mode class on body
|
||||
if (isSelectionMode) {
|
||||
iframeDoc.body.classList.add("roo-preview-selection-mode")
|
||||
} else {
|
||||
iframeDoc.body.classList.remove("roo-preview-selection-mode")
|
||||
}
|
||||
|
||||
// Mouse move handler for highlighting
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isSelectionMode) return
|
||||
|
||||
const target = e.target as HTMLElement
|
||||
if (target === highlightedElement) return
|
||||
|
||||
// Remove previous highlight
|
||||
if (highlightedElement) {
|
||||
highlightedElement.classList.remove("roo-preview-highlight")
|
||||
}
|
||||
|
||||
// Add new highlight
|
||||
target.classList.add("roo-preview-highlight")
|
||||
setHighlightedElement(target)
|
||||
}
|
||||
|
||||
// Click handler for selection
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (!isSelectionMode) return
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
const target = e.target as HTMLElement
|
||||
const context = extractElementContext(target)
|
||||
|
||||
// Send element context to extension
|
||||
vscode.postMessage({ type: "elementSelected", elementContext: context })
|
||||
|
||||
// Exit selection mode
|
||||
setIsSelectionMode(false)
|
||||
if (highlightedElement) {
|
||||
highlightedElement.classList.remove("roo-preview-highlight")
|
||||
setHighlightedElement(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Add event listeners
|
||||
iframeDoc.addEventListener("mousemove", handleMouseMove)
|
||||
iframeDoc.addEventListener("click", handleClick, true)
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
iframeDoc.removeEventListener("mousemove", handleMouseMove)
|
||||
iframeDoc.removeEventListener("click", handleClick, true)
|
||||
iframeDoc.body.classList.remove("roo-preview-selection-mode")
|
||||
if (highlightedElement) {
|
||||
highlightedElement.classList.remove("roo-preview-highlight")
|
||||
}
|
||||
}
|
||||
} catch (_error) {
|
||||
// Cross-origin iframe, can't access content
|
||||
console.warn("Cannot access iframe content due to cross-origin restrictions")
|
||||
}
|
||||
}, [isSelectionMode, highlightedElement, extractElementContext])
|
||||
|
||||
// Setup iframe interaction when selection mode changes or iframe loads
|
||||
useEffect(() => {
|
||||
if (iframeRef.current && state.url) {
|
||||
const cleanup = setupIframeInteraction()
|
||||
return cleanup
|
||||
}
|
||||
}, [isSelectionMode, state.url, setupIframeInteraction])
|
||||
|
||||
return (
|
||||
<div className="web-preview-container">
|
||||
<div className="web-preview-toolbar">
|
||||
<div className="toolbar-section">
|
||||
<input
|
||||
type="text"
|
||||
className="url-input"
|
||||
value={urlInput}
|
||||
onChange={(e) => setUrlInput(e.target.value)}
|
||||
onKeyPress={(e) => e.key === "Enter" && handleNavigate()}
|
||||
placeholder="Enter URL..."
|
||||
/>
|
||||
<VSCodeButton onClick={handleNavigate} disabled={state.isLoading}>
|
||||
Go
|
||||
</VSCodeButton>
|
||||
<VSCodeButton onClick={handleRefresh} disabled={state.isLoading}>
|
||||
<span className="codicon codicon-refresh"></span>
|
||||
</VSCodeButton>
|
||||
</div>
|
||||
|
||||
<div className="toolbar-section">
|
||||
<VSCodeDropdown value={state.deviceMode} onChange={handleDeviceModeChange}>
|
||||
<VSCodeOption value="desktop">Desktop (1200x800)</VSCodeOption>
|
||||
<VSCodeOption value="tablet">Tablet (768x1024)</VSCodeOption>
|
||||
<VSCodeOption value="mobile">Mobile (375x667)</VSCodeOption>
|
||||
<VSCodeOption value="custom">Custom</VSCodeOption>
|
||||
</VSCodeDropdown>
|
||||
|
||||
{state.deviceMode === "custom" && (
|
||||
<div className="custom-size-inputs">
|
||||
<input
|
||||
type="number"
|
||||
className="size-input"
|
||||
value={state.viewportSize.width}
|
||||
onChange={(e) =>
|
||||
handleViewportResize(parseInt(e.target.value), state.viewportSize.height)
|
||||
}
|
||||
placeholder="Width"
|
||||
/>
|
||||
<span>×</span>
|
||||
<input
|
||||
type="number"
|
||||
className="size-input"
|
||||
value={state.viewportSize.height}
|
||||
onChange={(e) =>
|
||||
handleViewportResize(state.viewportSize.width, parseInt(e.target.value))
|
||||
}
|
||||
placeholder="Height"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="toolbar-section">
|
||||
<VSCodeButton
|
||||
onClick={() => setIsSelectionMode(!isSelectionMode)}
|
||||
appearance={isSelectionMode ? "primary" : "secondary"}>
|
||||
<span className="codicon codicon-inspect"></span>
|
||||
{isSelectionMode ? "Selecting..." : "Select Element"}
|
||||
</VSCodeButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="web-preview-content">
|
||||
{state.isLoading && (
|
||||
<div className="loading-overlay">
|
||||
<span className="codicon codicon-loading codicon-modifier-spin"></span>
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.url ? (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={state.url}
|
||||
className="preview-iframe"
|
||||
style={{
|
||||
width: `${state.viewportSize.width}px`,
|
||||
height: `${state.viewportSize.height}px`,
|
||||
}}
|
||||
onLoad={() => {
|
||||
setState((prev) => ({ ...prev, isLoading: false }))
|
||||
setupIframeInteraction()
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="empty-state">
|
||||
<span className="codicon codicon-globe"></span>
|
||||
<p>Enter a URL to preview your web application</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{state.selectedElement && (
|
||||
<div className="element-info">
|
||||
<h4>Selected Element</h4>
|
||||
<div className="info-item">
|
||||
<strong>Selector:</strong> <code>{state.selectedElement.selector}</code>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<strong>Position:</strong> {state.selectedElement.position.x}x{state.selectedElement.position.y}
|
||||
({state.selectedElement.position.width}x{state.selectedElement.position.height})
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WebPreview
|
||||
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 WebPreview from "./components/web-preview/WebPreview"
|
||||
import "./index.css"
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById("root") as HTMLElement)
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<WebPreview />
|
||||
</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