velxio/vscode-extension/src/SimulatorPanel.ts

199 lines
6.1 KiB
TypeScript

/**
* SimulatorPanel — Manages the VS Code WebView panel that hosts the simulation UI.
*
* The WebView runs a stripped-down version of the Velxio React frontend
* (SimulatorCanvas + SerialMonitor) with simulation engines (avr8js, rp2040js)
* running directly in the WebView's JavaScript context.
*
* Communication with the extension host uses VS Code's postMessage API.
*/
import * as vscode from 'vscode';
import * as path from 'path';
import type { ToWebviewMessage, FromWebviewMessage, BoardKind } from './types';
export class SimulatorPanel {
public static readonly viewType = 'velxio.simulator';
private static instance: SimulatorPanel | undefined;
private readonly panel: vscode.WebviewPanel;
private readonly extensionUri: vscode.Uri;
private disposables: vscode.Disposable[] = [];
private _ready = false;
private _onSerialOutput = new vscode.EventEmitter<string>();
private _onSimulationState = new vscode.EventEmitter<boolean>();
private _onReady = new vscode.EventEmitter<void>();
/** Fired when serial data arrives from the simulation */
public readonly onSerialOutput = this._onSerialOutput.event;
/** Fired when simulation starts/stops */
public readonly onSimulationState = this._onSimulationState.event;
/** Fired when the WebView is ready */
public readonly onReady = this._onReady.event;
public get ready(): boolean { return this._ready; }
/** Get or create the singleton panel */
public static createOrShow(extensionUri: vscode.Uri): SimulatorPanel {
const column = vscode.ViewColumn.Beside;
if (SimulatorPanel.instance) {
SimulatorPanel.instance.panel.reveal(column);
return SimulatorPanel.instance;
}
const panel = vscode.window.createWebviewPanel(
SimulatorPanel.viewType,
'Velxio Simulator',
column,
{
enableScripts: true,
retainContextWhenHidden: true,
localResourceRoots: [
vscode.Uri.joinPath(extensionUri, 'dist', 'webview'),
vscode.Uri.joinPath(extensionUri, 'media'),
],
},
);
SimulatorPanel.instance = new SimulatorPanel(panel, extensionUri);
return SimulatorPanel.instance;
}
private constructor(panel: vscode.WebviewPanel, extensionUri: vscode.Uri) {
this.panel = panel;
this.extensionUri = extensionUri;
this.panel.webview.html = this.getHtmlContent();
// Handle messages from the WebView
this.panel.webview.onDidReceiveMessage(
(msg: FromWebviewMessage) => this.handleMessage(msg),
null,
this.disposables,
);
// Cleanup on dispose
this.panel.onDidDispose(() => this.dispose(), null, this.disposables);
}
/** Send a message to the WebView */
public postMessage(message: ToWebviewMessage): void {
if (this._ready) {
this.panel.webview.postMessage(message);
}
}
/** Send compiled hex to the WebView */
public loadHex(hex: string, board: BoardKind): void {
this.postMessage({ type: 'loadHex', hex, board });
}
/** Send firmware binary (base64) for ESP32 */
public loadBinary(firmwareBase64: string, board: BoardKind): void {
this.postMessage({ type: 'loadBinary', firmwareBase64, board });
}
/** Load MicroPython files */
public loadMicroPython(files: Array<{ name: string; content: string }>, board: BoardKind): void {
this.postMessage({ type: 'loadMicroPython', files, board });
}
/** Start the simulation */
public start(): void {
this.postMessage({ type: 'start' });
}
/** Stop the simulation */
public stop(): void {
this.postMessage({ type: 'stop' });
}
/** Send serial input text */
public serialInput(text: string): void {
this.postMessage({ type: 'serialInput', text });
}
/** Set the board type */
public setBoard(board: BoardKind): void {
this.postMessage({ type: 'setBoard', board });
}
/** Set the backend API base URL */
public setApiBase(apiBase: string): void {
this.postMessage({ type: 'setApiBase', apiBase });
}
private handleMessage(msg: FromWebviewMessage): void {
switch (msg.type) {
case 'ready':
this._ready = true;
this._onReady.fire();
break;
case 'serialOutput':
this._onSerialOutput.fire(msg.text);
break;
case 'simulationState':
this._onSimulationState.fire(msg.running);
break;
case 'error':
vscode.window.showErrorMessage(`Velxio: ${msg.message}`);
break;
case 'log':
// Forward WebView logs to the output channel
break;
}
}
private getHtmlContent(): string {
const webview = this.panel.webview;
const webviewDistUri = vscode.Uri.joinPath(this.extensionUri, 'dist', 'webview');
// In development, point to local files; in production, use bundled assets
const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(webviewDistUri, 'index.js'));
const styleUri = webview.asWebviewUri(vscode.Uri.joinPath(webviewDistUri, 'index.css'));
const nonce = getNonce();
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="
default-src 'none';
style-src ${webview.cspSource} 'unsafe-inline';
script-src 'nonce-${nonce}' 'unsafe-eval';
img-src ${webview.cspSource} data: https:;
font-src ${webview.cspSource};
connect-src http://127.0.0.1:* ws://127.0.0.1:* https://micropython.org;
">
<link rel="stylesheet" href="${styleUri}">
<title>Velxio Simulator</title>
</head>
<body>
<div id="root"></div>
<script nonce="${nonce}" src="${scriptUri}"></script>
</body>
</html>`;
}
private dispose(): void {
SimulatorPanel.instance = undefined;
this._onSerialOutput.dispose();
this._onSimulationState.dispose();
this._onReady.dispose();
for (const d of this.disposables) d.dispose();
this.disposables = [];
}
}
function getNonce(): string {
let text = '';
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
for (let i = 0; i < 32; i++) {
text += chars.charAt(Math.floor(Math.random() * chars.length));
}
return text;
}