Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .cursor/rules/vscode-stepzen-project.mdc
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
description:
globs:
alwaysApply: false
alwaysApply: true
---
# VSCode StepZen Extension Architecture Rules

Expand Down
123 changes: 123 additions & 0 deletions src/panels/BaseWebviewPanel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/**
* Copyright IBM Corp. 2025
* Assisted by CursorAI
*/

import * as vscode from "vscode";
import { Uri } from "vscode";

/**
* Abstract base class for webview panels providing common functionality
* including CSP handling, nonce generation, and URI resolution.
*/
export abstract class BaseWebviewPanel {
protected panel: vscode.WebviewPanel | undefined;
protected readonly extensionUri: Uri;

constructor(extensionUri: Uri) {
this.extensionUri = extensionUri;
}

/**
* Generates a random nonce for Content Security Policy
* Used to secure inline scripts in the webview
*
* @returns A random string to use as a nonce
*/
protected nonce(): string {
return [...Array(16)].map(() => Math.random().toString(36)[2]).join("");
}

/**
* Generates a Content Security Policy header for the webview
* Includes proper nonce and webview source handling
*
* @param webview The webview to generate CSP for
* @param nonce The nonce value to include in the CSP
* @returns CSP header string
*/
protected csp(webview: vscode.Webview, nonce: string): string {
return `default-src 'none'; img-src ${webview.cspSource}; style-src ${webview.cspSource}; script-src 'nonce-${nonce}' ${webview.cspSource};`;
}

/**
* Helper to get webview URIs for resources
* Resolves paths relative to the webview directory
*
* @param webview The webview to generate URIs for
* @param pathList Array of path segments relative to webview directory
* @returns Webview URI for the resource
*/
protected getWebviewUri(webview: vscode.Webview, pathList: string[]): vscode.Uri {
return webview.asWebviewUri(Uri.joinPath(this.extensionUri, "webview", ...pathList));
}

/**
* Creates the webview panel with standard options
*
* @param viewType Unique identifier for the webview type
* @param title Title to display in the panel
* @param viewColumn Column to show the panel in
* @param options Additional webview options
* @returns Created webview panel
*/
protected createWebviewPanel(
viewType: string,
title: string,
viewColumn: vscode.ViewColumn,
options?: Partial<vscode.WebviewOptions>
): vscode.WebviewPanel {
const defaultOptions: vscode.WebviewOptions = {
enableScripts: true,
localResourceRoots: [Uri.joinPath(this.extensionUri, "webview")],
};

const panel = vscode.window.createWebviewPanel(
viewType,
title,
viewColumn,
{ ...defaultOptions, ...options }
);

// Setup disposal handling
panel.onDidDispose(() => this.onDispose());

return panel;
}

/**
* Called when the panel is disposed
* Subclasses can override to perform cleanup
*/
protected onDispose(): void {
this.panel = undefined;
}

/**
* Disposes the panel if it exists
*/
public dispose(): void {
if (this.panel) {
this.panel.dispose();
this.panel = undefined;
}
}

/**
* Reveals the panel if it exists
*/
public reveal(): void {
if (this.panel) {
this.panel.reveal();
}
}

/**
* Abstract method that subclasses must implement to generate HTML content
*
* @param webview The webview to generate HTML for
* @param data Optional data to include in the HTML
* @returns HTML string for the webview
*/
protected abstract generateHtml(webview: vscode.Webview, data?: any): string;
}
208 changes: 109 additions & 99 deletions src/panels/resultsPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,121 +9,131 @@ import { Uri } from "vscode";
import { EXTENSION_URI } from "../extension";
import { StepZenResponse } from "../types";
import { UI } from "../utils/constants";

/** The singleton results panel instance */
let panel: vscode.WebviewPanel | undefined;
import { BaseWebviewPanel } from "./BaseWebviewPanel";

/**
* Opens or reveals the results panel and displays the payload
*
* @param payload The GraphQL response data to display
* Results panel implementation extending BaseWebviewPanel
* Displays GraphQL response data with tabs for data, errors, debug, and trace
*/
export async function openResultsPanel(payload: StepZenResponse) {
const extensionUri = EXTENSION_URI;
if (!panel) {
panel = vscode.window.createWebviewPanel(
UI.RESULTS_PANEL_VIEW_TYPE,
UI.RESULTS_PANEL_TITLE,
vscode.ViewColumn.Beside,
{
enableScripts: true,
localResourceRoots: [Uri.joinPath(extensionUri, "webview")],
}
);
panel.onDidDispose(() => (panel = undefined));
class ResultsPanel extends BaseWebviewPanel {
private static instance: ResultsPanel | undefined;

private constructor(extensionUri: Uri) {
super(extensionUri);
}

panel.webview.html = getHtml(panel.webview, extensionUri, payload);
panel.reveal();
}
/**
* Gets or creates the singleton results panel instance
*/
public static getInstance(): ResultsPanel {
if (!ResultsPanel.instance) {
ResultsPanel.instance = new ResultsPanel(EXTENSION_URI);
}
return ResultsPanel.instance;
}

/**
* Clears the results panel by disposing the webview panel
* Used when clearing results or when the extension is deactivated
*/
export function clearResultsPanel(): void {
if (panel) {
panel.dispose();
panel = undefined;
/**
* Opens or reveals the results panel and displays the payload
*/
public async openWithPayload(payload: StepZenResponse): Promise<void> {
if (!this.panel) {
this.panel = this.createWebviewPanel(
UI.RESULTS_PANEL_VIEW_TYPE,
UI.RESULTS_PANEL_TITLE,
vscode.ViewColumn.Beside
);
}

this.panel.webview.html = this.generateHtml(this.panel.webview, payload);
this.reveal();
}

/**
* Clears the results panel by disposing it
*/
public clear(): void {
this.dispose();
}

protected onDispose(): void {
super.onDispose();
ResultsPanel.instance = undefined;
}

protected generateHtml(webview: vscode.Webview, payload: StepZenResponse): string {
const nonce = this.nonce();
const payloadJs = JSON.stringify(payload);
const hasErrors = Array.isArray(payload?.errors) && payload.errors.length > 0;

return /* html */ `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Security-Policy" content="${this.csp(webview, nonce)}">
<title>${UI.RESULTS_PANEL_TITLE}</title>

<!-- Link to CSS file instead of inline styles -->
<link rel="stylesheet" href="${this.getWebviewUri(webview, ['css', 'results-panel.css'])}">
</head>
<body>
<div class="tabs">
<div class="tab active" data-id="data">Data</div>
${hasErrors ? '<div class="tab" data-id="errors">Errors</div>' : ''}
<div class="tab" data-id="debug">Debug</div>
<div class="tab" data-id="trace">Trace View</div>
</div>

<div id="pane-data" class="panel"></div>
${hasErrors ? '<div id="pane-errors" class="panel" hidden></div>' : ''}
<div id="pane-debug" class="panel" hidden></div>
<div id="pane-trace" class="panel" hidden></div>

<!-- Load React libraries -->
<script nonce="${nonce}" src="${this.getWebviewUri(webview, ['libs', 'react.production.min.js'])}"></script>
<script nonce="${nonce}" src="${this.getWebviewUri(webview, ['libs', 'react-dom.production.min.js'])}"></script>
<script nonce="${nonce}" src="${this.getWebviewUri(webview, ['libs', 'react-json-view.min.js'])}"></script>

<!-- Load our custom scripts -->
<script nonce="${nonce}" src="${this.getWebviewUri(webview, ['js', 'trace-viewer.js'])}"></script>
<script nonce="${nonce}" src="${this.getWebviewUri(webview, ['js', 'results-panel.js'])}"></script>

<!-- Initialize the panel -->
<script nonce="${nonce}">
// Initialize when the DOM is ready
document.addEventListener('DOMContentLoaded', () => {
const payload = ${payloadJs};
window.ResultsPanel.initResultsPanel(payload);
});
</script>
</body>
</html>
`;
}
}

/*─────────────────────────────────────────────────────────*/
/** The singleton results panel instance */
let resultsPanel: ResultsPanel | undefined;

/**
* Generates the HTML content for the results panel webview
* Opens or reveals the results panel and displays the payload
*
* @param webview The webview to generate HTML for
* @param extUri The extension URI for resource loading
* @param payload The GraphQL response data to display
* @returns HTML string for the webview
*/
function getHtml(
webview: vscode.Webview,
extUri: Uri,
payload: StepZenResponse
): string {
// Helper to get webview URIs
const getUri = (pathList: string[]) => {
return webview.asWebviewUri(Uri.joinPath(extUri, "webview", ...pathList));
};

const nonce = getNonce();
const payloadJs = JSON.stringify(payload);
const hasErrors = Array.isArray(payload?.errors) && payload.errors.length > 0;

return /* html */ `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${webview.cspSource}; style-src ${webview.cspSource}; script-src 'nonce-${nonce}' ${webview.cspSource};">
<title>${UI.RESULTS_PANEL_TITLE}</title>

<!-- Link to CSS file instead of inline styles -->
<link rel="stylesheet" href="${getUri(['css', 'results-panel.css'])}">
</head>
<body>
<div class="tabs">
<div class="tab active" data-id="data">Data</div>
${hasErrors ? '<div class="tab" data-id="errors">Errors</div>' : ''}
<div class="tab" data-id="debug">Debug</div>
<div class="tab" data-id="trace">Trace View</div>
</div>

<div id="pane-data" class="panel"></div>
${hasErrors ? '<div id="pane-errors" class="panel" hidden></div>' : ''}
<div id="pane-debug" class="panel" hidden></div>
<div id="pane-trace" class="panel" hidden></div>

<!-- Load React libraries -->
<script nonce="${nonce}" src="${getUri(['libs', 'react.production.min.js'])}"></script>
<script nonce="${nonce}" src="${getUri(['libs', 'react-dom.production.min.js'])}"></script>
<script nonce="${nonce}" src="${getUri(['libs', 'react-json-view.min.js'])}"></script>

<!-- Load our custom scripts -->
<script nonce="${nonce}" src="${getUri(['js', 'trace-viewer.js'])}"></script>
<script nonce="${nonce}" src="${getUri(['js', 'results-panel.js'])}"></script>

<!-- Initialize the panel -->
<script nonce="${nonce}">
// Initialize when the DOM is ready
document.addEventListener('DOMContentLoaded', () => {
const payload = ${payloadJs};
window.ResultsPanel.initResultsPanel(payload);
});
</script>
</body>
</html>
`;
export async function openResultsPanel(payload: StepZenResponse) {
if (!resultsPanel) {
resultsPanel = ResultsPanel.getInstance();
}
await resultsPanel.openWithPayload(payload);
}

/**
* Generates a random nonce for Content Security Policy
* Used to secure inline scripts in the webview
*
* @returns A random string to use as a nonce
* Clears the results panel by disposing the webview panel
* Used when clearing results or when the extension is deactivated
*/
function getNonce() {
return [...Array(16)].map(() => Math.random().toString(36)[2]).join("");
export function clearResultsPanel(): void {
if (resultsPanel) {
resultsPanel.clear();
resultsPanel = undefined;
}
}
Loading