import * as vscode from 'vscode';
import * as fs from 'fs';
export interface WebviewMessage {
type: string;
id?: string;
payload?: any;
}
export interface WebviewResponse {
id: string;
success: boolean;
data?: any;
error?: string;
}
/**
* Configuration for WebviewProvider
*/
export interface WebviewProviderConfig {
viewType: string;
title: string;
mediaFolder: string;
}
/**
* Core Webview Provider - generic webview panel management
* Commands are registered from outside to add functionality
*/
export class WebviewProvider {
private _panel?: vscode.WebviewPanel;
private _extensionUri: vscode.Uri;
private _config: WebviewProviderConfig;
private _pendingRequests: Map<string, {
resolve: (value: any) => void;
reject: (error: Error) => void;
}> = new Map();
constructor(extensionUri: vscode.Uri, config?: Partial<WebviewProviderConfig>) {
this._extensionUri = extensionUri;
this._config = {
viewType: config?.viewType || 'webview.mainView',
title: config?.title || 'Webview',
mediaFolder: config?.mediaFolder || 'media',
};
}
/**
* Check if the panel is currently open
*/
public isPanelOpen(): boolean {
return this._panel !== undefined;
}
/**
* Create or show the webview panel in an editor tab
*/
public createOrShowPanel(): boolean {
const column = vscode.window.activeTextEditor
? vscode.window.activeTextEditor.viewColumn
: undefined;
// If we already have a panel, show it
if (this._panel) {
this._panel.reveal(column);
return true;
}
const mediaPath = vscode.Uri.joinPath(this._extensionUri, this._config.mediaFolder);
// Create a new panel
this._panel = vscode.window.createWebviewPanel(
this._config.viewType,
this._config.title,
column || vscode.ViewColumn.One,
{
enableScripts: true,
retainContextWhenHidden: true,
localResourceRoots: [mediaPath]
}
);
this._panel.webview.html = this._getHtmlContent(this._panel.webview, mediaPath);
// Handle messages from webview
this._panel.webview.onDidReceiveMessage(
(message: WebviewResponse) => {
if (message.id && this._pendingRequests.has(message.id)) {
const request = this._pendingRequests.get(message.id)!;
this._pendingRequests.delete(message.id);
if (message.success) {
request.resolve(message.data);
} else {
request.reject(new Error(message.error || 'Unknown error'));
}
}
}
);
// Reset when the panel is disposed
this._panel.onDidDispose(() => {
this._panel = undefined;
});
return true;
}
/**
* Close the webview panel
*/
public closePanel(): boolean {
if (this._panel) {
this._panel.dispose();
this._panel = undefined;
return true;
}
return false;
}
/**
* Send command to webview and wait for response
*/
public async sendCommand(type: string, payload?: any): Promise<any> {
if (!this._panel) {
throw new Error('Webview is not initialized');
}
const id = this._generateId();
const message: WebviewMessage = { type, id, payload };
return new Promise((resolve, reject) => {
this._pendingRequests.set(id, { resolve, reject });
// Timeout after 30 seconds
setTimeout(() => {
if (this._pendingRequests.has(id)) {
this._pendingRequests.delete(id);
reject(new Error('Request timeout'));
}
}, 30000);
this._panel!.webview.postMessage(message);
});
}
private _generateId(): string {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
private _getHtmlContent(webview: vscode.Webview, mediaPath: vscode.Uri): string {
const indexPath = vscode.Uri.joinPath(mediaPath, 'index.html');
// Check if custom SPA exists
if (fs.existsSync(indexPath.fsPath)) {
return this._loadHtml(webview, mediaPath);
}
// Return default placeholder
return this._getDefaultHtml(webview, mediaPath);
}
private _loadHtml(webview: vscode.Webview, mediaPath: vscode.Uri): string {
const indexPath = vscode.Uri.joinPath(mediaPath, 'index.html');
let html = fs.readFileSync(indexPath.fsPath, 'utf-8');
// Convert relative paths to webview URIs
html = html.replace(/(href|src)="([^"]+)"/g, (match, attr, value) => {
if (value.startsWith('http') || value.startsWith('data:')) {
return match;
}
const uri = webview.asWebviewUri(vscode.Uri.joinPath(mediaPath, value));
return `${attr}="${uri}"`;
});
// Inject bundle script if exists (Vite output)
const bundlePath = vscode.Uri.joinPath(mediaPath, 'bundle.js');
if (fs.existsSync(bundlePath.fsPath)) {
const bundleUri = webview.asWebviewUri(bundlePath);
const bundleScript = `<script src="${bundleUri}"></script>`;
html = html.replace('</body>', `${bundleScript}</body>`);
}
return html;
}
private _getDefaultHtml(webview: vscode.Webview, mediaPath: vscode.Uri): string {
// Check for bundle.js (Vite output)
const bundlePath = vscode.Uri.joinPath(mediaPath, 'bundle.js');
const hasBundle = fs.existsSync(bundlePath.fsPath);
const bundleScript = hasBundle
? `<script src="${webview.asWebviewUri(bundlePath)}"></script>`
: '';
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 'unsafe-inline'; script-src ${webview.cspSource};">
<title>${this._config.title}</title>
<style>
body {
font-family: var(--vscode-font-family);
color: var(--vscode-foreground);
background-color: var(--vscode-editor-background);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
margin: 0;
padding: 20px;
box-sizing: border-box;
}
.container {
text-align: center;
max-width: 400px;
}
h1 {
font-size: 1.5em;
margin-bottom: 10px;
}
p {
opacity: 0.8;
line-height: 1.5;
}
.icon {
font-size: 4em;
margin-bottom: 20px;
}
code {
background: var(--vscode-textBlockQuote-background);
padding: 2px 6px;
border-radius: 3px;
}
</style>
</head>
<body>
<div class="container">
<div class="icon">📱</div>
<h1>${this._config.title}</h1>
<p>No content loaded yet.</p>
<p>Place your files in the <code>${this._config.mediaFolder}/</code> folder.</p>
</div>
${bundleScript}
</body>
</html>`;
}
}
// Re-export with alias for backward compatibility
export { WebviewProvider as WebViewerProvider };