diff --git a/packages/types/src/tool.ts b/packages/types/src/tool.ts index 7a3fd21199..4c4fd8aec4 100644 --- a/packages/types/src/tool.ts +++ b/packages/types/src/tool.ts @@ -34,6 +34,7 @@ export const toolNames = [ "fetch_instructions", "codebase_search", "update_todo_list", + "web_preview", ] as const export const toolNamesSchema = z.enum(toolNames) diff --git a/packages/types/src/vscode.ts b/packages/types/src/vscode.ts index 00f6bbbcba..2dd191bab7 100644 --- a/packages/types/src/vscode.ts +++ b/packages/types/src/vscode.ts @@ -53,6 +53,8 @@ export const commandIds = [ "focusInput", "acceptInput", "focusPanel", + "openWebPreview", + "getSelectedElement", ] as const export type CommandId = (typeof commandIds)[number] diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index bd925b0e90..7f7f76f935 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -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) => { diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index ee3fa148b4..8e348a97de 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -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 diff --git a/src/core/tools/webPreviewTool.ts b/src/core/tools/webPreviewTool.ts new file mode 100644 index 0000000000..13cbed0be7 --- /dev/null +++ b/src/core/tools/webPreviewTool.ts @@ -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))) + } +} diff --git a/src/core/webview/WebPreviewProvider.ts b/src/core/webview/WebPreviewProvider.ts new file mode 100644 index 0000000000..93edf8ef41 --- /dev/null +++ b/src/core/webview/WebPreviewProvider.ts @@ -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 + attributes?: Record +} + +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 ` + + + + + + + + Web Preview + + +
+ + + + ` + } +} diff --git a/src/extension.ts b/src/extension.ts index bd43bcbf8a..0d4d0d1345 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -22,6 +22,7 @@ import { Package } from "./shared/package" import { formatLanguage } from "./shared/language" import { ContextProxy } from "./core/config/ContextProxy" import { ClineProvider } from "./core/webview/ClineProvider" +import { WebPreviewProvider } from "./core/webview/WebPreviewProvider" import { DIFF_VIEW_URI_SCHEME } from "./integrations/editor/DiffViewProvider" import { TerminalRegistry } from "./integrations/terminal/TerminalRegistry" import { McpServerManager } from "./services/mcp/McpServerManager" @@ -121,6 +122,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 diff --git a/src/package.json b/src/package.json index 5e3cd3bc53..a643d1a62e 100644 --- a/src/package.json +++ b/src/package.json @@ -66,6 +66,12 @@ "type": "webview", "id": "roo-cline.SidebarProvider", "name": "%views.sidebar.name%" + }, + { + "type": "webview", + "id": "roo-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": { diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 4f2aa2da15..d039fdc15f 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -107,6 +107,7 @@ export interface ExtensionMessage { | "codeIndexSecretStatus" | "showDeleteMessageDialog" | "showEditMessageDialog" + | "webPreviewElementSelected" text?: string payload?: any // Add a generic payload for now, can refine later action?: @@ -161,6 +162,7 @@ export interface ExtensionMessage { settings?: any messageTs?: number context?: string + elementContext?: 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. diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 1f56829f7b..7b49f1837e 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -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({ diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 67972243fe..b5e81a9a53 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -190,6 +190,7 @@ export const TOOL_DISPLAY_NAMES: Record = { 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. diff --git a/webview-ui/src/components/web-preview/WebPreview.css b/webview-ui/src/components/web-preview/WebPreview.css new file mode 100644 index 0000000000..48f88227ca --- /dev/null +++ b/webview-ui/src/components/web-preview/WebPreview.css @@ -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; +} diff --git a/webview-ui/src/components/web-preview/WebPreview.tsx b/webview-ui/src/components/web-preview/WebPreview.tsx new file mode 100644 index 0000000000..70672fd2d4 --- /dev/null +++ b/webview-ui/src/components/web-preview/WebPreview.tsx @@ -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 + attributes?: Record +} + +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(null) + const [state, setState] = useState({ + isLoading: false, + viewportSize: { width: 1200, height: 800 }, + deviceMode: "desktop", + }) + const [urlInput, setUrlInput] = useState("") + const [isSelectionMode, setIsSelectionMode] = useState(false) + const [highlightedElement, setHighlightedElement] = useState(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 = {} + 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 = {} + 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 ( +
+
+
+ setUrlInput(e.target.value)} + onKeyPress={(e) => e.key === "Enter" && handleNavigate()} + placeholder="Enter URL..." + /> + + Go + + + + +
+ +
+ + Desktop (1200x800) + Tablet (768x1024) + Mobile (375x667) + Custom + + + {state.deviceMode === "custom" && ( +
+ + handleViewportResize(parseInt(e.target.value), state.viewportSize.height) + } + placeholder="Width" + /> + × + + handleViewportResize(state.viewportSize.width, parseInt(e.target.value)) + } + placeholder="Height" + /> +
+ )} +
+ +
+ setIsSelectionMode(!isSelectionMode)} + appearance={isSelectionMode ? "primary" : "secondary"}> + + {isSelectionMode ? "Selecting..." : "Select Element"} + +
+
+ +
+ {state.isLoading && ( +
+ + Loading... +
+ )} + + {state.url ? ( +