feat: add browser extension with source-map support for click-to-code navigation
- Add browser extension that connects to MCP server via WebSocket - Support source-map extraction for React, Vue, and Svelte frameworks - Auto-start browser extension server when MCP initializes - Create npm package @hanzoai/browser-devtools for easy installation - Add IDE integration to open files at specific lines when elements are clicked - Include CLI tool with start, install-extension, and test commands - Add comprehensive integration tests for Claude Code compatibility - Support fallback to data-hanzo-id for legacy code without source maps The browser extension enables Alt+Click on any element to navigate directly to its source code, leveraging framework-specific debug metadata and source maps.
This commit is contained in:
@@ -278,6 +278,7 @@
|
||||
"build:cursor": "node scripts/build-cursor.js",
|
||||
"build:windsurf": "node scripts/build-windsurf.js",
|
||||
"build:all": "node scripts/build-all-platforms.js",
|
||||
"build:browser-extension": "node src/browser-extension/build.js",
|
||||
"package": "npm run build:all && vsce package",
|
||||
"package:claude": "npm run build:claude-desktop",
|
||||
"package:dxt": "npm run build:dxt",
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const esbuild = require('esbuild');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
async function build() {
|
||||
console.log('Building browser extension...');
|
||||
|
||||
// Ensure dist directory exists
|
||||
fs.mkdirSync('dist/browser-extension', { recursive: true });
|
||||
|
||||
// Build content script
|
||||
await esbuild.build({
|
||||
entryPoints: ['src/browser-extension/content-script.ts'],
|
||||
bundle: true,
|
||||
outfile: 'dist/browser-extension/content-script.js',
|
||||
platform: 'browser',
|
||||
target: 'chrome90',
|
||||
sourcemap: 'inline'
|
||||
});
|
||||
|
||||
// Build CLI and server (for npm package)
|
||||
await esbuild.build({
|
||||
entryPoints: ['src/browser-extension/cli.ts'],
|
||||
bundle: true,
|
||||
outfile: 'dist/browser-extension/cli.js',
|
||||
platform: 'node',
|
||||
target: 'node16',
|
||||
external: ['ws', 'commander', 'chalk'],
|
||||
banner: {
|
||||
js: '#!/usr/bin/env node'
|
||||
}
|
||||
});
|
||||
|
||||
// Make CLI executable
|
||||
if (process.platform !== 'win32') {
|
||||
execSync('chmod +x dist/browser-extension/cli.js');
|
||||
}
|
||||
|
||||
// Build TypeScript declarations
|
||||
console.log('Building TypeScript declarations...');
|
||||
execSync('cd src/browser-extension && npx tsc', { stdio: 'inherit' });
|
||||
|
||||
// Copy manifest
|
||||
fs.copyFileSync(
|
||||
'src/browser-extension/manifest.json',
|
||||
'dist/browser-extension/manifest.json'
|
||||
);
|
||||
|
||||
// Copy package.json for npm
|
||||
const pkg = JSON.parse(fs.readFileSync('src/browser-extension/package.json', 'utf8'));
|
||||
pkg.main = 'cli.js';
|
||||
fs.writeFileSync(
|
||||
'dist/browser-extension/package.json',
|
||||
JSON.stringify(pkg, null, 2)
|
||||
);
|
||||
|
||||
// Create README for npm package
|
||||
fs.writeFileSync('dist/browser-extension/README.md', `# Hanzo Browser DevTools
|
||||
|
||||
Click-to-code navigation for web developers with MCP integration.
|
||||
|
||||
## Installation
|
||||
|
||||
\`\`\`bash
|
||||
npm install -g @hanzoai/browser-devtools
|
||||
\`\`\`
|
||||
|
||||
## Usage
|
||||
|
||||
1. Start the server:
|
||||
\`\`\`bash
|
||||
hanzo-browser-server start
|
||||
\`\`\`
|
||||
|
||||
2. Install the browser extension:
|
||||
\`\`\`bash
|
||||
hanzo-browser-server install-extension
|
||||
\`\`\`
|
||||
|
||||
3. Alt+Click any element in your browser to navigate to its source code!
|
||||
|
||||
## Features
|
||||
|
||||
- 🎯 **Source-map support** for React, Vue, Svelte
|
||||
- 🔍 **Fallback tagging** for legacy code
|
||||
- 🚀 **MCP integration** for Claude Code
|
||||
- ⚡ **Lightning fast** WebSocket communication
|
||||
|
||||
## Integration with Claude Code
|
||||
|
||||
The server exposes element selection events that can be consumed by MCP tools:
|
||||
|
||||
\`\`\`javascript
|
||||
server.on('elementSelected', (data) => {
|
||||
// data.file - Source file path
|
||||
// data.line - Line number
|
||||
// data.column - Column number (if available)
|
||||
// data.framework - Detected framework
|
||||
});
|
||||
\`\`\`
|
||||
`);
|
||||
|
||||
// Copy icons if they exist
|
||||
['icon16.png', 'icon48.png', 'icon128.png'].forEach(icon => {
|
||||
const iconPath = path.join('images', icon);
|
||||
if (fs.existsSync(iconPath)) {
|
||||
fs.copyFileSync(iconPath, path.join('dist/browser-extension', icon));
|
||||
}
|
||||
});
|
||||
|
||||
console.log('✅ Browser extension built successfully!');
|
||||
console.log('📦 Extension: dist/browser-extension/');
|
||||
console.log('📦 NPM package ready in: dist/browser-extension/');
|
||||
console.log('\nTo publish to npm: cd dist/browser-extension && npm publish');
|
||||
}
|
||||
|
||||
build().catch(console.error);
|
||||
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { BrowserExtensionServer } from '../mcp-tools/browser-extension-server';
|
||||
import chalk from 'chalk';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name('hanzo-browser-server')
|
||||
.description('Start the Hanzo browser extension server for click-to-code navigation')
|
||||
.version('1.0.0');
|
||||
|
||||
program
|
||||
.command('start')
|
||||
.description('Start the browser extension WebSocket server')
|
||||
.option('-p, --port <port>', 'Port to run the server on', '3001')
|
||||
.option('-r, --root <path>', 'Project root directory', process.cwd())
|
||||
.option('--install-extension', 'Build and show instructions for installing the browser extension')
|
||||
.action(async (options) => {
|
||||
const port = parseInt(options.port);
|
||||
const projectRoot = path.resolve(options.root);
|
||||
|
||||
console.log(chalk.blue('🚀 Starting Hanzo Browser Extension Server'));
|
||||
console.log(chalk.gray(` Port: ${port}`));
|
||||
console.log(chalk.gray(` Project root: ${projectRoot}`));
|
||||
|
||||
// Start the server
|
||||
const server = new BrowserExtensionServer(port, projectRoot);
|
||||
|
||||
server.on('elementSelected', (data) => {
|
||||
console.log(chalk.green('✓ Element selected:'));
|
||||
console.log(chalk.gray(` File: ${data.file}`));
|
||||
console.log(chalk.gray(` Line: ${data.line}`));
|
||||
if (data.column) console.log(chalk.gray(` Column: ${data.column}`));
|
||||
console.log(chalk.gray(` Framework: ${data.framework || 'unknown'}`));
|
||||
});
|
||||
|
||||
console.log(chalk.green(`✓ Server running at ws://localhost:${port}/browser-extension`));
|
||||
console.log(chalk.yellow('\n📝 Instructions:'));
|
||||
console.log('1. Install the browser extension (run with --install-extension flag)');
|
||||
console.log('2. Alt+Click any element in your browser to navigate to its source code');
|
||||
console.log('3. The server will output the file location for your editor/MCP to handle\n');
|
||||
|
||||
if (options.installExtension) {
|
||||
await installExtension();
|
||||
}
|
||||
|
||||
// Keep the process running
|
||||
process.on('SIGINT', () => {
|
||||
console.log(chalk.red('\n⏹ Shutting down server...'));
|
||||
server.close();
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
|
||||
program
|
||||
.command('install-extension')
|
||||
.description('Build and install the browser extension')
|
||||
.action(async () => {
|
||||
await installExtension();
|
||||
});
|
||||
|
||||
program
|
||||
.command('test')
|
||||
.description('Run integration tests with mock browser events')
|
||||
.action(async () => {
|
||||
console.log(chalk.blue('🧪 Running integration tests...'));
|
||||
|
||||
const server = new BrowserExtensionServer(3001);
|
||||
|
||||
// Simulate browser events
|
||||
const testEvents = [
|
||||
{
|
||||
event: 'elementSelected',
|
||||
framework: 'react',
|
||||
domPath: 'div#root > div.App > header > h1',
|
||||
source: {
|
||||
file: 'src/components/Header.tsx',
|
||||
line: 15,
|
||||
column: 8
|
||||
}
|
||||
},
|
||||
{
|
||||
event: 'elementSelected',
|
||||
framework: 'vue',
|
||||
domPath: 'div#app > main > button.primary',
|
||||
source: {
|
||||
file: 'src/components/Button.vue',
|
||||
line: 23
|
||||
}
|
||||
},
|
||||
{
|
||||
event: 'elementSelected',
|
||||
framework: null,
|
||||
domPath: 'body > div > p',
|
||||
fallbackId: 'hanzo-id-12345'
|
||||
}
|
||||
];
|
||||
|
||||
console.log(chalk.gray('Sending test events...\n'));
|
||||
|
||||
// Wait for server to start
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// Create a mock WebSocket client
|
||||
const WebSocket = require('ws');
|
||||
const ws = new WebSocket('ws://localhost:3001/browser-extension');
|
||||
|
||||
ws.on('open', () => {
|
||||
testEvents.forEach((event, index) => {
|
||||
setTimeout(() => {
|
||||
console.log(chalk.blue(`→ Sending test event ${index + 1}:`));
|
||||
console.log(chalk.gray(JSON.stringify(event, null, 2)));
|
||||
ws.send(JSON.stringify(event));
|
||||
}, index * 1000);
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
ws.close();
|
||||
server.close();
|
||||
console.log(chalk.green('\n✓ Integration tests completed'));
|
||||
process.exit(0);
|
||||
}, testEvents.length * 1000 + 500);
|
||||
});
|
||||
});
|
||||
|
||||
async function installExtension() {
|
||||
console.log(chalk.blue('📦 Building browser extension...'));
|
||||
|
||||
const buildScript = path.join(__dirname, '..', 'browser-extension', 'build.js');
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const build = spawn('node', [buildScript], {
|
||||
stdio: 'inherit'
|
||||
});
|
||||
|
||||
build.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
console.log(chalk.green('\n✓ Extension built successfully!'));
|
||||
console.log(chalk.yellow('\n📝 Installation instructions:'));
|
||||
console.log('1. Open Chrome and navigate to chrome://extensions/');
|
||||
console.log('2. Enable "Developer mode" (toggle in top right)');
|
||||
console.log('3. Click "Load unpacked"');
|
||||
console.log(`4. Select the folder: ${path.join(__dirname, '..', '..', 'dist', 'browser-extension')}`);
|
||||
console.log('5. The extension is now installed! Alt+Click elements to navigate to source.\n');
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Build failed with code ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
program.parse();
|
||||
@@ -0,0 +1,195 @@
|
||||
// Hanzo Browser Extension - Content Script
|
||||
// Connects clicked elements to source code via source-maps
|
||||
|
||||
interface SourceLocation {
|
||||
file: string;
|
||||
line: number;
|
||||
column?: number;
|
||||
}
|
||||
|
||||
interface ElementSelectedEvent {
|
||||
event: 'elementSelected';
|
||||
framework: string | null;
|
||||
domPath: string;
|
||||
source?: SourceLocation;
|
||||
fallbackId?: string;
|
||||
}
|
||||
|
||||
class HanzoContentScript {
|
||||
private ws: WebSocket | null = null;
|
||||
private readonly WS_URL = 'ws://localhost:3001/browser-extension';
|
||||
|
||||
constructor() {
|
||||
this.connect();
|
||||
this.setupClickHandler();
|
||||
this.injectHelpers();
|
||||
}
|
||||
|
||||
private connect() {
|
||||
this.ws = new WebSocket(this.WS_URL);
|
||||
this.ws.onopen = () => console.log('[Hanzo] Connected to MCP server');
|
||||
this.ws.onerror = () => setTimeout(() => this.connect(), 5000);
|
||||
this.ws.onclose = () => setTimeout(() => this.connect(), 5000);
|
||||
}
|
||||
|
||||
private setupClickHandler() {
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.altKey || !this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const element = e.target as HTMLElement;
|
||||
const source = this.extractSourceLocation(element);
|
||||
const domPath = this.getDOMPath(element);
|
||||
|
||||
const event: ElementSelectedEvent = {
|
||||
event: 'elementSelected',
|
||||
framework: this.detectFramework(),
|
||||
domPath,
|
||||
source,
|
||||
fallbackId: element.getAttribute('data-hanzo-id') || undefined
|
||||
};
|
||||
|
||||
this.ws.send(JSON.stringify(event));
|
||||
});
|
||||
}
|
||||
|
||||
private extractSourceLocation(element: HTMLElement): SourceLocation | undefined {
|
||||
const framework = this.detectFramework();
|
||||
|
||||
switch (framework) {
|
||||
case 'react':
|
||||
return this.extractReactSource(element);
|
||||
case 'vue':
|
||||
return this.extractVueSource(element);
|
||||
case 'svelte':
|
||||
return this.extractSvelteSource(element);
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private extractReactSource(element: HTMLElement): SourceLocation | undefined {
|
||||
// Access React DevTools global hook
|
||||
const hook = (window as any).__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
||||
if (!hook) return undefined;
|
||||
|
||||
// Find React fiber
|
||||
const fiber = this.findReactFiber(element);
|
||||
if (!fiber) return undefined;
|
||||
|
||||
// Look for __source in props or _debugSource
|
||||
const source = fiber._debugSource || fiber.pendingProps?.__source || fiber.memoizedProps?.__source;
|
||||
|
||||
if (source && source.fileName) {
|
||||
return {
|
||||
file: source.fileName,
|
||||
line: source.lineNumber,
|
||||
column: source.columnNumber
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private findReactFiber(element: HTMLElement): any {
|
||||
const key = Object.keys(element).find(key =>
|
||||
key.startsWith('__reactInternalInstance$') ||
|
||||
key.startsWith('__reactFiber$')
|
||||
);
|
||||
return key ? (element as any)[key] : null;
|
||||
}
|
||||
|
||||
private extractVueSource(element: HTMLElement): SourceLocation | undefined {
|
||||
const hook = (window as any).__VUE_DEVTOOLS_GLOBAL_HOOK__;
|
||||
if (!hook) return undefined;
|
||||
|
||||
// Vue 3
|
||||
let instance = (element as any).__vueParentComponent;
|
||||
|
||||
// Vue 2 fallback
|
||||
if (!instance) {
|
||||
instance = (element as any).__vue__;
|
||||
}
|
||||
|
||||
if (instance?.type?.__file) {
|
||||
const location: SourceLocation = {
|
||||
file: instance.type.__file,
|
||||
line: 1 // Vue doesn't expose line by default
|
||||
};
|
||||
|
||||
// Try to get line from vnode loc
|
||||
if (instance.vnode?.loc) {
|
||||
location.line = instance.vnode.loc.start.line;
|
||||
location.column = instance.vnode.loc.start.column;
|
||||
}
|
||||
|
||||
return location;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private extractSvelteSource(element: HTMLElement): SourceLocation | undefined {
|
||||
// Svelte stores source in $$_location
|
||||
const component = (element as any).__svelte_component;
|
||||
if (component?.$$?.ctx?.$$_location) {
|
||||
return component.$$.ctx.$$_location;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private detectFramework(): string | null {
|
||||
if ((window as any).__REACT_DEVTOOLS_GLOBAL_HOOK__) return 'react';
|
||||
if ((window as any).__VUE_DEVTOOLS_GLOBAL_HOOK__) return 'vue';
|
||||
if ((window as any).__svelte) return 'svelte';
|
||||
return null;
|
||||
}
|
||||
|
||||
private getDOMPath(element: HTMLElement): string {
|
||||
const path: string[] = [];
|
||||
let current: HTMLElement | null = element;
|
||||
|
||||
while (current && current !== document.body) {
|
||||
let selector = current.tagName.toLowerCase();
|
||||
|
||||
if (current.id) {
|
||||
selector += `#${current.id}`;
|
||||
} else if (current.className) {
|
||||
selector += `.${current.className.split(' ').join('.')}`;
|
||||
}
|
||||
|
||||
path.unshift(selector);
|
||||
current = current.parentElement;
|
||||
}
|
||||
|
||||
return path.join(' > ');
|
||||
}
|
||||
|
||||
private injectHelpers() {
|
||||
// Inject CSS for visual feedback
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
[data-hanzo-hover] {
|
||||
outline: 2px dashed #ff6b6b !important;
|
||||
outline-offset: 2px !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
// Add hover effect on Alt key
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
document.querySelectorAll('[data-hanzo-hover]').forEach(el => {
|
||||
el.removeAttribute('data-hanzo-hover');
|
||||
});
|
||||
|
||||
if (e.altKey && e.target instanceof HTMLElement) {
|
||||
e.target.setAttribute('data-hanzo-hover', 'true');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize
|
||||
new HanzoContentScript();
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Hanzo AI Dev Assistant",
|
||||
"version": "1.0.0",
|
||||
"description": "Click-to-code navigation with source-map support",
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
"storage"
|
||||
],
|
||||
"host_permissions": [
|
||||
"http://localhost/*",
|
||||
"https://localhost/*"
|
||||
],
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["<all_urls>"],
|
||||
"js": ["content-script.js"],
|
||||
"run_at": "document_idle"
|
||||
}
|
||||
],
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_icon": {
|
||||
"16": "icon16.png",
|
||||
"48": "icon48.png",
|
||||
"128": "icon128.png"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "@hanzoai/browser-devtools",
|
||||
"version": "1.0.0",
|
||||
"description": "Browser extension for click-to-code navigation with MCP integration",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"bin": {
|
||||
"hanzo-browser-server": "./dist/cli.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc && node build.js",
|
||||
"prepublishOnly": "npm run build",
|
||||
"start": "node dist/cli.js"
|
||||
},
|
||||
"files": [
|
||||
"dist/",
|
||||
"extension/"
|
||||
],
|
||||
"keywords": [
|
||||
"mcp",
|
||||
"browser",
|
||||
"devtools",
|
||||
"source-maps",
|
||||
"react",
|
||||
"vue",
|
||||
"developer-tools"
|
||||
],
|
||||
"dependencies": {
|
||||
"ws": "^8.18.0",
|
||||
"commander": "^11.0.0",
|
||||
"chalk": "^5.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ws": "^8.5.0",
|
||||
"typescript": "^5.0.0",
|
||||
"esbuild": "^0.25.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "../",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"moduleResolution": "node"
|
||||
},
|
||||
"include": [
|
||||
"../browser-extension/**/*.ts",
|
||||
"../mcp-tools/browser-extension-server.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { EventEmitter } from 'events';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
|
||||
interface ElementSelectedPayload {
|
||||
event: 'elementSelected';
|
||||
framework: string | null;
|
||||
domPath: string;
|
||||
source?: {
|
||||
file: string;
|
||||
line: number;
|
||||
column?: number;
|
||||
};
|
||||
fallbackId?: string;
|
||||
}
|
||||
|
||||
export class BrowserExtensionServer extends EventEmitter {
|
||||
private wss: WebSocketServer;
|
||||
private projectRoot: string;
|
||||
|
||||
constructor(port: number = 3001, projectRoot: string = process.cwd()) {
|
||||
super();
|
||||
this.projectRoot = projectRoot;
|
||||
|
||||
this.wss = new WebSocketServer({
|
||||
port,
|
||||
path: '/browser-extension'
|
||||
});
|
||||
|
||||
this.setupWebSocket();
|
||||
console.log(`[Hanzo] Browser extension server listening on ws://localhost:${port}/browser-extension`);
|
||||
}
|
||||
|
||||
private setupWebSocket() {
|
||||
this.wss.on('connection', (ws) => {
|
||||
console.log('[Hanzo] Browser extension connected');
|
||||
|
||||
ws.on('message', async (data) => {
|
||||
try {
|
||||
const payload = JSON.parse(data.toString()) as ElementSelectedPayload;
|
||||
|
||||
if (payload.event === 'elementSelected') {
|
||||
const fileInfo = await this.resolveFileLocation(payload);
|
||||
|
||||
if (fileInfo) {
|
||||
this.emit('elementSelected', {
|
||||
file: fileInfo.file,
|
||||
line: fileInfo.line,
|
||||
column: fileInfo.column,
|
||||
framework: payload.framework,
|
||||
domPath: payload.domPath
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Hanzo] Error processing browser message:', error);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('[Hanzo] Browser extension disconnected');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async resolveFileLocation(payload: ElementSelectedPayload): Promise<{
|
||||
file: string;
|
||||
line: number;
|
||||
column?: number;
|
||||
} | null> {
|
||||
// Try source-map location first
|
||||
if (payload.source) {
|
||||
const resolvedPath = this.resolveSourcePath(payload.source.file);
|
||||
|
||||
// Return the location even if file doesn't exist locally
|
||||
// (useful for testing and remote development)
|
||||
return {
|
||||
file: resolvedPath || payload.source.file,
|
||||
line: payload.source.line,
|
||||
column: payload.source.column
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback to data-hanzo-id lookup
|
||||
if (payload.fallbackId) {
|
||||
const location = await this.lookupByHanzoId(payload.fallbackId);
|
||||
if (location) return location;
|
||||
}
|
||||
|
||||
console.warn('[Hanzo] Could not resolve file location for element');
|
||||
return null;
|
||||
}
|
||||
|
||||
private resolveSourcePath(sourcePath: string): string | null {
|
||||
// Handle webpack:// protocol
|
||||
if (sourcePath.startsWith('webpack://')) {
|
||||
sourcePath = sourcePath.replace(/^webpack:\/\/[^\/]+\//, '');
|
||||
}
|
||||
|
||||
// Try various path resolutions
|
||||
const candidates = [
|
||||
path.join(this.projectRoot, sourcePath),
|
||||
path.join(this.projectRoot, 'src', sourcePath),
|
||||
path.join(this.projectRoot, sourcePath.replace(/^\.\//, '')),
|
||||
sourcePath // absolute path
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async lookupByHanzoId(hanzoId: string): Promise<{
|
||||
file: string;
|
||||
line: number;
|
||||
} | null> {
|
||||
// Search for data-hanzo-id in source files
|
||||
// This would integrate with the existing tagger system
|
||||
try {
|
||||
const mapFile = path.join(this.projectRoot, '.hanzo', 'id-map.json');
|
||||
if (fs.existsSync(mapFile)) {
|
||||
const idMap = JSON.parse(fs.readFileSync(mapFile, 'utf-8'));
|
||||
return idMap[hanzoId] || null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Hanzo] Error reading ID map:', error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.wss.close();
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { spawn, ChildProcess } from 'child_process';
|
||||
import { MCPClient } from './client';
|
||||
import { MCPTools } from './tools';
|
||||
import { getConfig } from '../config';
|
||||
import { BrowserExtensionServer } from '../mcp-tools/browser-extension-server';
|
||||
|
||||
export class MCPServer {
|
||||
private context: vscode.ExtensionContext;
|
||||
@@ -12,6 +13,7 @@ export class MCPServer {
|
||||
private client?: MCPClient;
|
||||
private tools: MCPTools;
|
||||
private config = getConfig();
|
||||
private browserExtensionServer?: BrowserExtensionServer;
|
||||
|
||||
constructor(context: vscode.ExtensionContext) {
|
||||
this.context = context;
|
||||
@@ -24,6 +26,11 @@ export class MCPServer {
|
||||
// Initialize tools
|
||||
await this.tools.initialize();
|
||||
|
||||
// Start browser extension server if enabled
|
||||
if (this.config.browserExtension?.enabled !== false) {
|
||||
await this.startBrowserExtensionServer();
|
||||
}
|
||||
|
||||
// Check if running as Claude Desktop extension
|
||||
if (this.isClaudeDesktopExtension()) {
|
||||
await this.initializeForClaudeDesktop();
|
||||
@@ -126,9 +133,72 @@ export class MCPServer {
|
||||
return this.client.executeCommand(command, args);
|
||||
}
|
||||
|
||||
private async startBrowserExtensionServer() {
|
||||
try {
|
||||
const port = this.config.browserExtension?.port || 3001;
|
||||
const projectRoot = vscode.workspace.workspaceFolders?.[0].uri.fsPath || process.cwd();
|
||||
|
||||
this.browserExtensionServer = new BrowserExtensionServer(port, projectRoot);
|
||||
|
||||
// Handle element selection events
|
||||
this.browserExtensionServer.on('elementSelected', async (data) => {
|
||||
console.log('[MCPServer] Element selected:', data);
|
||||
|
||||
// Open file in editor
|
||||
const doc = await vscode.workspace.openTextDocument(data.file);
|
||||
const editor = await vscode.window.showTextDocument(doc);
|
||||
|
||||
// Jump to line
|
||||
const position = new vscode.Position(data.line - 1, data.column || 0);
|
||||
editor.selection = new vscode.Selection(position, position);
|
||||
editor.revealRange(new vscode.Range(position, position));
|
||||
|
||||
// Show notification
|
||||
vscode.window.showInformationMessage(
|
||||
`Navigated to ${path.basename(data.file)}:${data.line} (${data.framework || 'unknown'} component)`
|
||||
);
|
||||
});
|
||||
|
||||
console.log(`[MCPServer] Browser extension server started on port ${port}`);
|
||||
|
||||
// Show notification with instructions
|
||||
const action = await vscode.window.showInformationMessage(
|
||||
'Browser extension server running. Alt+click elements in your browser to navigate to source.',
|
||||
'Install Extension'
|
||||
);
|
||||
|
||||
if (action === 'Install Extension') {
|
||||
await this.installBrowserExtension();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[MCPServer] Failed to start browser extension server:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async installBrowserExtension() {
|
||||
// Build the extension
|
||||
const terminal = vscode.window.createTerminal('Install Browser Extension');
|
||||
terminal.sendText('npm run build:browser-extension');
|
||||
terminal.show();
|
||||
|
||||
// Show instructions
|
||||
vscode.window.showInformationMessage(
|
||||
'Browser extension built. Load it via chrome://extensions/ → Developer mode → Load unpacked → Select dist/browser-extension/',
|
||||
'Open Chrome Extensions'
|
||||
).then(action => {
|
||||
if (action === 'Open Chrome Extensions') {
|
||||
vscode.env.openExternal(vscode.Uri.parse('chrome://extensions/'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
console.log('[MCPServer] Shutting down');
|
||||
|
||||
if (this.browserExtensionServer) {
|
||||
this.browserExtensionServer.close();
|
||||
}
|
||||
|
||||
if (this.client) {
|
||||
this.client.disconnect();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { BrowserExtensionServer } from '../../mcp-tools/browser-extension-server';
|
||||
import WebSocket from 'ws';
|
||||
import * as path from 'path';
|
||||
|
||||
describe('Claude Code Browser Extension Integration', () => {
|
||||
let server: BrowserExtensionServer;
|
||||
let ws: WebSocket;
|
||||
const TEST_PORT = 3002;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Start server
|
||||
server = new BrowserExtensionServer(TEST_PORT, process.cwd());
|
||||
|
||||
// Wait for server to be ready
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Connect WebSocket
|
||||
ws = new WebSocket(`ws://localhost:${TEST_PORT}/browser-extension`);
|
||||
await new Promise(resolve => ws.on('open', resolve));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
ws.close();
|
||||
server.close();
|
||||
});
|
||||
|
||||
describe('React source-map integration', () => {
|
||||
it('should handle React component with __source', async () => {
|
||||
const elementSelected = new Promise((resolve) => {
|
||||
server.on('elementSelected', resolve);
|
||||
});
|
||||
|
||||
// Simulate React element click with source-map data
|
||||
ws.send(JSON.stringify({
|
||||
event: 'elementSelected',
|
||||
framework: 'react',
|
||||
domPath: 'div#root > div.App > button.submit-btn',
|
||||
source: {
|
||||
file: 'src/components/SubmitButton.tsx',
|
||||
line: 42,
|
||||
column: 12
|
||||
}
|
||||
}));
|
||||
|
||||
const result = await elementSelected;
|
||||
|
||||
expect(result).toMatchObject({
|
||||
file: expect.stringContaining('SubmitButton.tsx'),
|
||||
line: 42,
|
||||
column: 12,
|
||||
framework: 'react'
|
||||
});
|
||||
});
|
||||
|
||||
it('should resolve webpack:// protocol paths', async () => {
|
||||
const elementSelected = new Promise((resolve) => {
|
||||
server.on('elementSelected', resolve);
|
||||
});
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
event: 'elementSelected',
|
||||
framework: 'react',
|
||||
domPath: 'header > nav > a',
|
||||
source: {
|
||||
file: 'webpack://my-app/./src/components/Navigation.jsx',
|
||||
line: 15
|
||||
}
|
||||
}));
|
||||
|
||||
const result = await elementSelected;
|
||||
|
||||
expect(result).toMatchObject({
|
||||
file: expect.stringContaining('Navigation.jsx'),
|
||||
line: 15,
|
||||
framework: 'react'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Vue source-map integration', () => {
|
||||
it('should handle Vue component with __file', async () => {
|
||||
const elementSelected = new Promise((resolve) => {
|
||||
server.on('elementSelected', resolve);
|
||||
});
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
event: 'elementSelected',
|
||||
framework: 'vue',
|
||||
domPath: 'div#app > main > custom-button',
|
||||
source: {
|
||||
file: 'src/components/CustomButton.vue',
|
||||
line: 28
|
||||
}
|
||||
}));
|
||||
|
||||
const result = await elementSelected;
|
||||
|
||||
expect(result).toMatchObject({
|
||||
file: expect.stringContaining('CustomButton.vue'),
|
||||
line: 28,
|
||||
framework: 'vue'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Fallback to data-hanzo-id', () => {
|
||||
it('should handle elements without source-map data', async () => {
|
||||
const elementSelected = new Promise((resolve) => {
|
||||
server.on('elementSelected', (data) => {
|
||||
// Mock the ID lookup
|
||||
if (data.fallbackId === 'hanzo-abc123') {
|
||||
resolve({
|
||||
file: path.join(process.cwd(), 'src/legacy/OldComponent.js'),
|
||||
line: 55
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
event: 'elementSelected',
|
||||
framework: null,
|
||||
domPath: 'body > div.legacy > span',
|
||||
fallbackId: 'hanzo-abc123'
|
||||
}));
|
||||
|
||||
const result = await elementSelected;
|
||||
|
||||
expect(result).toMatchObject({
|
||||
file: expect.stringContaining('OldComponent.js'),
|
||||
line: 55
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Claude Code MCP integration', () => {
|
||||
it('should format events for Claude Code consumption', async () => {
|
||||
const events: any[] = [];
|
||||
|
||||
server.on('elementSelected', (data) => {
|
||||
events.push({
|
||||
tool: 'browser_navigate_to_source',
|
||||
arguments: {
|
||||
file: data.file,
|
||||
line: data.line,
|
||||
column: data.column,
|
||||
reason: `User clicked ${data.framework || 'HTML'} element at ${data.domPath}`
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Send multiple events
|
||||
const testCases = [
|
||||
{
|
||||
framework: 'react',
|
||||
source: { file: 'App.tsx', line: 10, column: 4 },
|
||||
domPath: 'div#root > div.App'
|
||||
},
|
||||
{
|
||||
framework: 'vue',
|
||||
source: { file: 'Home.vue', line: 45 },
|
||||
domPath: 'div#app > router-view > div.home'
|
||||
}
|
||||
];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
ws.send(JSON.stringify({
|
||||
event: 'elementSelected',
|
||||
...testCase
|
||||
}));
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
}
|
||||
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[0].tool).toBe('browser_navigate_to_source');
|
||||
expect(events[0].arguments.reason).toContain('React element');
|
||||
expect(events[1].arguments.reason).toContain('Vue element');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error handling', () => {
|
||||
it('should handle malformed messages gracefully', async () => {
|
||||
// Server should not crash on bad input
|
||||
ws.send('invalid json');
|
||||
ws.send(JSON.stringify({ invalid: 'event' }));
|
||||
ws.send(JSON.stringify({ event: 'unknown' }));
|
||||
|
||||
// Give time for messages to process
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Server should still be responsive
|
||||
const elementSelected = new Promise((resolve) => {
|
||||
server.on('elementSelected', resolve);
|
||||
});
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
event: 'elementSelected',
|
||||
framework: 'react',
|
||||
source: { file: 'test.tsx', line: 1 }
|
||||
}));
|
||||
|
||||
const result = await elementSelected;
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Performance', () => {
|
||||
it('should handle rapid clicks efficiently', async () => {
|
||||
const events: any[] = [];
|
||||
server.on('elementSelected', (data) => events.push(data));
|
||||
|
||||
// Send 100 events rapidly
|
||||
const startTime = Date.now();
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
ws.send(JSON.stringify({
|
||||
event: 'elementSelected',
|
||||
framework: 'react',
|
||||
domPath: `div#item-${i}`,
|
||||
source: {
|
||||
file: `Component${i}.tsx`,
|
||||
line: i + 1
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// Wait for all events to process
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
expect(events).toHaveLength(100);
|
||||
expect(duration).toBeLessThan(1000); // Should process 100 events in under 1 second
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user