Skip to content

Commit a0988cb

Browse files
added base clase for panels. updated two panels to use base class. added panel tests. (#69)
1 parent c19ca51 commit a0988cb

File tree

8 files changed

+1234
-357
lines changed

8 files changed

+1234
-357
lines changed

.cursor/rules/vscode-stepzen-project.mdc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
description:
33
globs:
4-
alwaysApply: false
4+
alwaysApply: true
55
---
66
# VSCode StepZen Extension Architecture Rules
77

src/panels/BaseWebviewPanel.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/**
2+
* Copyright IBM Corp. 2025
3+
* Assisted by CursorAI
4+
*/
5+
6+
import * as vscode from "vscode";
7+
import { Uri } from "vscode";
8+
9+
/**
10+
* Abstract base class for webview panels providing common functionality
11+
* including CSP handling, nonce generation, and URI resolution.
12+
*/
13+
export abstract class BaseWebviewPanel {
14+
protected panel: vscode.WebviewPanel | undefined;
15+
protected readonly extensionUri: Uri;
16+
17+
constructor(extensionUri: Uri) {
18+
this.extensionUri = extensionUri;
19+
}
20+
21+
/**
22+
* Generates a random nonce for Content Security Policy
23+
* Used to secure inline scripts in the webview
24+
*
25+
* @returns A random string to use as a nonce
26+
*/
27+
protected nonce(): string {
28+
return [...Array(16)].map(() => Math.random().toString(36)[2]).join("");
29+
}
30+
31+
/**
32+
* Generates a Content Security Policy header for the webview
33+
* Includes proper nonce and webview source handling
34+
*
35+
* @param webview The webview to generate CSP for
36+
* @param nonce The nonce value to include in the CSP
37+
* @returns CSP header string
38+
*/
39+
protected csp(webview: vscode.Webview, nonce: string): string {
40+
return `default-src 'none'; img-src ${webview.cspSource}; style-src ${webview.cspSource}; script-src 'nonce-${nonce}' ${webview.cspSource};`;
41+
}
42+
43+
/**
44+
* Helper to get webview URIs for resources
45+
* Resolves paths relative to the webview directory
46+
*
47+
* @param webview The webview to generate URIs for
48+
* @param pathList Array of path segments relative to webview directory
49+
* @returns Webview URI for the resource
50+
*/
51+
protected getWebviewUri(webview: vscode.Webview, pathList: string[]): vscode.Uri {
52+
return webview.asWebviewUri(Uri.joinPath(this.extensionUri, "webview", ...pathList));
53+
}
54+
55+
/**
56+
* Creates the webview panel with standard options
57+
*
58+
* @param viewType Unique identifier for the webview type
59+
* @param title Title to display in the panel
60+
* @param viewColumn Column to show the panel in
61+
* @param options Additional webview options
62+
* @returns Created webview panel
63+
*/
64+
protected createWebviewPanel(
65+
viewType: string,
66+
title: string,
67+
viewColumn: vscode.ViewColumn,
68+
options?: Partial<vscode.WebviewOptions>
69+
): vscode.WebviewPanel {
70+
const defaultOptions: vscode.WebviewOptions = {
71+
enableScripts: true,
72+
localResourceRoots: [Uri.joinPath(this.extensionUri, "webview")],
73+
};
74+
75+
const panel = vscode.window.createWebviewPanel(
76+
viewType,
77+
title,
78+
viewColumn,
79+
{ ...defaultOptions, ...options }
80+
);
81+
82+
// Setup disposal handling
83+
panel.onDidDispose(() => this.onDispose());
84+
85+
return panel;
86+
}
87+
88+
/**
89+
* Called when the panel is disposed
90+
* Subclasses can override to perform cleanup
91+
*/
92+
protected onDispose(): void {
93+
this.panel = undefined;
94+
}
95+
96+
/**
97+
* Disposes the panel if it exists
98+
*/
99+
public dispose(): void {
100+
if (this.panel) {
101+
this.panel.dispose();
102+
this.panel = undefined;
103+
}
104+
}
105+
106+
/**
107+
* Reveals the panel if it exists
108+
*/
109+
public reveal(): void {
110+
if (this.panel) {
111+
this.panel.reveal();
112+
}
113+
}
114+
115+
/**
116+
* Abstract method that subclasses must implement to generate HTML content
117+
*
118+
* @param webview The webview to generate HTML for
119+
* @param data Optional data to include in the HTML
120+
* @returns HTML string for the webview
121+
*/
122+
protected abstract generateHtml(webview: vscode.Webview, data?: any): string;
123+
}

src/panels/resultsPanel.ts

Lines changed: 109 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -9,121 +9,131 @@ import { Uri } from "vscode";
99
import { EXTENSION_URI } from "../extension";
1010
import { StepZenResponse } from "../types";
1111
import { UI } from "../utils/constants";
12-
13-
/** The singleton results panel instance */
14-
let panel: vscode.WebviewPanel | undefined;
12+
import { BaseWebviewPanel } from "./BaseWebviewPanel";
1513

1614
/**
17-
* Opens or reveals the results panel and displays the payload
18-
*
19-
* @param payload The GraphQL response data to display
15+
* Results panel implementation extending BaseWebviewPanel
16+
* Displays GraphQL response data with tabs for data, errors, debug, and trace
2017
*/
21-
export async function openResultsPanel(payload: StepZenResponse) {
22-
const extensionUri = EXTENSION_URI;
23-
if (!panel) {
24-
panel = vscode.window.createWebviewPanel(
25-
UI.RESULTS_PANEL_VIEW_TYPE,
26-
UI.RESULTS_PANEL_TITLE,
27-
vscode.ViewColumn.Beside,
28-
{
29-
enableScripts: true,
30-
localResourceRoots: [Uri.joinPath(extensionUri, "webview")],
31-
}
32-
);
33-
panel.onDidDispose(() => (panel = undefined));
18+
class ResultsPanel extends BaseWebviewPanel {
19+
private static instance: ResultsPanel | undefined;
20+
21+
private constructor(extensionUri: Uri) {
22+
super(extensionUri);
3423
}
3524

36-
panel.webview.html = getHtml(panel.webview, extensionUri, payload);
37-
panel.reveal();
38-
}
25+
/**
26+
* Gets or creates the singleton results panel instance
27+
*/
28+
public static getInstance(): ResultsPanel {
29+
if (!ResultsPanel.instance) {
30+
ResultsPanel.instance = new ResultsPanel(EXTENSION_URI);
31+
}
32+
return ResultsPanel.instance;
33+
}
3934

40-
/**
41-
* Clears the results panel by disposing the webview panel
42-
* Used when clearing results or when the extension is deactivated
43-
*/
44-
export function clearResultsPanel(): void {
45-
if (panel) {
46-
panel.dispose();
47-
panel = undefined;
35+
/**
36+
* Opens or reveals the results panel and displays the payload
37+
*/
38+
public async openWithPayload(payload: StepZenResponse): Promise<void> {
39+
if (!this.panel) {
40+
this.panel = this.createWebviewPanel(
41+
UI.RESULTS_PANEL_VIEW_TYPE,
42+
UI.RESULTS_PANEL_TITLE,
43+
vscode.ViewColumn.Beside
44+
);
45+
}
46+
47+
this.panel.webview.html = this.generateHtml(this.panel.webview, payload);
48+
this.reveal();
49+
}
50+
51+
/**
52+
* Clears the results panel by disposing it
53+
*/
54+
public clear(): void {
55+
this.dispose();
56+
}
57+
58+
protected onDispose(): void {
59+
super.onDispose();
60+
ResultsPanel.instance = undefined;
61+
}
62+
63+
protected generateHtml(webview: vscode.Webview, payload: StepZenResponse): string {
64+
const nonce = this.nonce();
65+
const payloadJs = JSON.stringify(payload);
66+
const hasErrors = Array.isArray(payload?.errors) && payload.errors.length > 0;
67+
68+
return /* html */ `
69+
<!DOCTYPE html>
70+
<html lang="en">
71+
<head>
72+
<meta charset="UTF-8">
73+
<meta http-equiv="Content-Security-Policy" content="${this.csp(webview, nonce)}">
74+
<title>${UI.RESULTS_PANEL_TITLE}</title>
75+
76+
<!-- Link to CSS file instead of inline styles -->
77+
<link rel="stylesheet" href="${this.getWebviewUri(webview, ['css', 'results-panel.css'])}">
78+
</head>
79+
<body>
80+
<div class="tabs">
81+
<div class="tab active" data-id="data">Data</div>
82+
${hasErrors ? '<div class="tab" data-id="errors">Errors</div>' : ''}
83+
<div class="tab" data-id="debug">Debug</div>
84+
<div class="tab" data-id="trace">Trace View</div>
85+
</div>
86+
87+
<div id="pane-data" class="panel"></div>
88+
${hasErrors ? '<div id="pane-errors" class="panel" hidden></div>' : ''}
89+
<div id="pane-debug" class="panel" hidden></div>
90+
<div id="pane-trace" class="panel" hidden></div>
91+
92+
<!-- Load React libraries -->
93+
<script nonce="${nonce}" src="${this.getWebviewUri(webview, ['libs', 'react.production.min.js'])}"></script>
94+
<script nonce="${nonce}" src="${this.getWebviewUri(webview, ['libs', 'react-dom.production.min.js'])}"></script>
95+
<script nonce="${nonce}" src="${this.getWebviewUri(webview, ['libs', 'react-json-view.min.js'])}"></script>
96+
97+
<!-- Load our custom scripts -->
98+
<script nonce="${nonce}" src="${this.getWebviewUri(webview, ['js', 'trace-viewer.js'])}"></script>
99+
<script nonce="${nonce}" src="${this.getWebviewUri(webview, ['js', 'results-panel.js'])}"></script>
100+
101+
<!-- Initialize the panel -->
102+
<script nonce="${nonce}">
103+
// Initialize when the DOM is ready
104+
document.addEventListener('DOMContentLoaded', () => {
105+
const payload = ${payloadJs};
106+
window.ResultsPanel.initResultsPanel(payload);
107+
});
108+
</script>
109+
</body>
110+
</html>
111+
`;
48112
}
49113
}
50114

51-
/*─────────────────────────────────────────────────────────*/
115+
/** The singleton results panel instance */
116+
let resultsPanel: ResultsPanel | undefined;
52117

53118
/**
54-
* Generates the HTML content for the results panel webview
119+
* Opens or reveals the results panel and displays the payload
55120
*
56-
* @param webview The webview to generate HTML for
57-
* @param extUri The extension URI for resource loading
58121
* @param payload The GraphQL response data to display
59-
* @returns HTML string for the webview
60122
*/
61-
function getHtml(
62-
webview: vscode.Webview,
63-
extUri: Uri,
64-
payload: StepZenResponse
65-
): string {
66-
// Helper to get webview URIs
67-
const getUri = (pathList: string[]) => {
68-
return webview.asWebviewUri(Uri.joinPath(extUri, "webview", ...pathList));
69-
};
70-
71-
const nonce = getNonce();
72-
const payloadJs = JSON.stringify(payload);
73-
const hasErrors = Array.isArray(payload?.errors) && payload.errors.length > 0;
74-
75-
return /* html */ `
76-
<!DOCTYPE html>
77-
<html lang="en">
78-
<head>
79-
<meta charset="UTF-8">
80-
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${webview.cspSource}; style-src ${webview.cspSource}; script-src 'nonce-${nonce}' ${webview.cspSource};">
81-
<title>${UI.RESULTS_PANEL_TITLE}</title>
82-
83-
<!-- Link to CSS file instead of inline styles -->
84-
<link rel="stylesheet" href="${getUri(['css', 'results-panel.css'])}">
85-
</head>
86-
<body>
87-
<div class="tabs">
88-
<div class="tab active" data-id="data">Data</div>
89-
${hasErrors ? '<div class="tab" data-id="errors">Errors</div>' : ''}
90-
<div class="tab" data-id="debug">Debug</div>
91-
<div class="tab" data-id="trace">Trace View</div>
92-
</div>
93-
94-
<div id="pane-data" class="panel"></div>
95-
${hasErrors ? '<div id="pane-errors" class="panel" hidden></div>' : ''}
96-
<div id="pane-debug" class="panel" hidden></div>
97-
<div id="pane-trace" class="panel" hidden></div>
98-
99-
<!-- Load React libraries -->
100-
<script nonce="${nonce}" src="${getUri(['libs', 'react.production.min.js'])}"></script>
101-
<script nonce="${nonce}" src="${getUri(['libs', 'react-dom.production.min.js'])}"></script>
102-
<script nonce="${nonce}" src="${getUri(['libs', 'react-json-view.min.js'])}"></script>
103-
104-
<!-- Load our custom scripts -->
105-
<script nonce="${nonce}" src="${getUri(['js', 'trace-viewer.js'])}"></script>
106-
<script nonce="${nonce}" src="${getUri(['js', 'results-panel.js'])}"></script>
107-
108-
<!-- Initialize the panel -->
109-
<script nonce="${nonce}">
110-
// Initialize when the DOM is ready
111-
document.addEventListener('DOMContentLoaded', () => {
112-
const payload = ${payloadJs};
113-
window.ResultsPanel.initResultsPanel(payload);
114-
});
115-
</script>
116-
</body>
117-
</html>
118-
`;
123+
export async function openResultsPanel(payload: StepZenResponse) {
124+
if (!resultsPanel) {
125+
resultsPanel = ResultsPanel.getInstance();
126+
}
127+
await resultsPanel.openWithPayload(payload);
119128
}
120129

121130
/**
122-
* Generates a random nonce for Content Security Policy
123-
* Used to secure inline scripts in the webview
124-
*
125-
* @returns A random string to use as a nonce
131+
* Clears the results panel by disposing the webview panel
132+
* Used when clearing results or when the extension is deactivated
126133
*/
127-
function getNonce() {
128-
return [...Array(16)].map(() => Math.random().toString(36)[2]).join("");
134+
export function clearResultsPanel(): void {
135+
if (resultsPanel) {
136+
resultsPanel.clear();
137+
resultsPanel = undefined;
138+
}
129139
}

0 commit comments

Comments
 (0)