diff --git a/.cursor/rules/vscode-stepzen-project.mdc b/.cursor/rules/vscode-stepzen-project.mdc index 5a8c8de..2bd4cae 100644 --- a/.cursor/rules/vscode-stepzen-project.mdc +++ b/.cursor/rules/vscode-stepzen-project.mdc @@ -1,7 +1,7 @@ --- description: globs: -alwaysApply: false +alwaysApply: true --- # VSCode StepZen Extension Architecture Rules diff --git a/src/panels/BaseWebviewPanel.ts b/src/panels/BaseWebviewPanel.ts new file mode 100644 index 0000000..5406827 --- /dev/null +++ b/src/panels/BaseWebviewPanel.ts @@ -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.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; +} \ No newline at end of file diff --git a/src/panels/resultsPanel.ts b/src/panels/resultsPanel.ts index 6e81417..c82e465 100644 --- a/src/panels/resultsPanel.ts +++ b/src/panels/resultsPanel.ts @@ -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 { + 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 */ ` + + + + + + ${UI.RESULTS_PANEL_TITLE} + + + + + +
+
Data
+ ${hasErrors ? '
Errors
' : ''} +
Debug
+
Trace View
+
+ +
+ ${hasErrors ? '' : ''} + + + + + + + + + + + + + + + + + `; } } -/*─────────────────────────────────────────────────────────*/ +/** 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 */ ` - - - - - - ${UI.RESULTS_PANEL_TITLE} - - - - - -
-
Data
- ${hasErrors ? '
Errors
' : ''} -
Debug
-
Trace View
-
- -
- ${hasErrors ? '' : ''} - - - - - - - - - - - - - - - - - `; +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; + } } \ No newline at end of file diff --git a/src/panels/schemaVisualizerPanel.ts b/src/panels/schemaVisualizerPanel.ts index fc63d93..3052b57 100644 --- a/src/panels/schemaVisualizerPanel.ts +++ b/src/panels/schemaVisualizerPanel.ts @@ -16,6 +16,7 @@ import { resolveStepZenProjectRoot } from "../utils/stepzenProject"; import * as path from "path"; import * as fs from "fs"; import { MESSAGES, FILE_PATTERNS, UI } from "../utils/constants"; +import { BaseWebviewPanel } from "./BaseWebviewPanel"; /** * Model representing the GraphQL schema for visualization @@ -34,31 +35,91 @@ interface SchemaVisualizerModel { relationships: TypeRelationship[]; } -/** The singleton schema visualizer panel instance */ -let panel: vscode.WebviewPanel | undefined; +/** + * Schema visualizer panel implementation extending BaseWebviewPanel + * Displays GraphQL schema as an interactive diagram + */ +class SchemaVisualizerPanel extends BaseWebviewPanel { + private static instance: SchemaVisualizerPanel | undefined; + private messageHandler: vscode.Disposable | undefined; -export async function openSchemaVisualizerPanel( - extensionUri: Uri, - focusedType?: string, -) { - services.logger.info( - `Opening Schema Visualizer${focusedType ? ` focused on type: ${focusedType}` : ""}`, - ); - - // Create panel if it doesn't exist - if (!panel) { - panel = vscode.window.createWebviewPanel( - UI.SCHEMA_VISUALIZER_VIEW_TYPE, - UI.SCHEMA_VISUALIZER_TITLE, - vscode.ViewColumn.Beside, - { - enableScripts: true, - localResourceRoots: [Uri.joinPath(extensionUri, "webview")], - }, + private constructor(extensionUri: Uri) { + super(extensionUri); + } + + /** + * Gets or creates the singleton schema visualizer panel instance + */ + public static getInstance(extensionUri: Uri): SchemaVisualizerPanel { + if (!SchemaVisualizerPanel.instance) { + SchemaVisualizerPanel.instance = new SchemaVisualizerPanel(extensionUri); + } + return SchemaVisualizerPanel.instance; + } + + /** + * Opens or reveals the schema visualizer panel + */ + public async openWithFocus(focusedType?: string): Promise { + services.logger.info( + `Opening Schema Visualizer${focusedType ? ` focused on type: ${focusedType}` : ""}`, ); - // Setup message handling - const messageHandler = panel.webview.onDidReceiveMessage((message) => { + // Create panel if it doesn't exist + if (!this.panel) { + this.panel = this.createWebviewPanel( + UI.SCHEMA_VISUALIZER_VIEW_TYPE, + UI.SCHEMA_VISUALIZER_TITLE, + vscode.ViewColumn.Beside + ); + + // Setup message handling + this.setupMessageHandling(); + + // Initially show loading state + this.panel.webview.html = this.getLoadingHtml(); + } + + this.reveal(); + + try { + // Ensure schema data is loaded + const dataLoaded = await this.ensureSchemaDataLoaded(); + + if (!dataLoaded) { + this.panel.webview.html = this.getNoProjectHtml(); + return; + } + + // Build the schema model for visualization + const schemaModel = this.buildSchemaModel(); + + // Debug logging + services.logger.debug( + `Schema model built: ${Object.keys(schemaModel.types).length} types, ${ + Object.keys(schemaModel.fields).length + } fields with entries, ${schemaModel.relationships.length} relationships`, + ); + + if (Object.keys(schemaModel.types).length === 0) { + services.logger.warn("No types found in schema model"); + this.panel.webview.html = this.getNoProjectHtml(); + return; + } + + // Update the webview with the schema data + this.panel.webview.html = this.generateHtml(this.panel.webview, { schemaModel, focusedType }); + + } catch (error) { + services.logger.error(`Error loading schema visualizer`, error); + this.panel.webview.html = this.getNoProjectHtml(); + } + } + + private setupMessageHandling(): void { + if (!this.panel) {return;} + + this.messageHandler = this.panel.webview.onDidReceiveMessage((message) => { switch (message.command) { case "navigateToLocation": const uri = vscode.Uri.file(message.location.uri); @@ -82,26 +143,28 @@ export async function openSchemaVisualizerPanel( return; } }); + } - // Clean up when panel is closed - panel.onDidDispose( - () => { - messageHandler.dispose(); - panel = undefined; - }, - null, - [], - ); - - // Initially show loading state - panel.webview.html = ` + protected onDispose(): void { + if (this.messageHandler) { + this.messageHandler.dispose(); + this.messageHandler = undefined; + } + super.onDispose(); + SchemaVisualizerPanel.instance = undefined; + } + + private getLoadingHtml(): string { + const nonce = this.nonce(); + return ` + StepZen Schema Visualizer - + + +
+

No StepZen Project Found

+

+ ${MESSAGES.STEPZEN_PROJECT_DESCRIPTION} +

+
+ + + `; } - return model; -} - -/** - * Generates HTML for an error message when no StepZen project is found - * - * @returns HTML string for the error message - */ -function getNoProjectHtml(): string { - return ` - - - - - StepZen Schema Visualizer - - - -
-

No StepZen Project Found

-

- ${MESSAGES.STEPZEN_PROJECT_DESCRIPTION} -

-
- - - `; -} - -/** - * Generates the HTML for the schema visualizer webview panel - */ -/** - * Generates the HTML for the schema visualizer webview panel - * Includes all necessary scripts, styles, and data for the visualization - * - * @param webview The webview to generate HTML for - * @param extUri The extension URI for resource loading - * @param schemaModel The schema model to visualize - * @param focusedType Optional type name to focus on initially - * @returns HTML string for the webview - */ -function getSchemaVisualizerHtml( - webview: vscode.Webview, - extUri: Uri, - schemaModel: SchemaVisualizerModel, - focusedType?: string, -): string { - // Helper to get webview URIs - const getUri = (pathList: string[]) => { - return webview.asWebviewUri(Uri.joinPath(extUri, "webview", ...pathList)); - }; - - // Load resources - const jointJsUri = getUri(["libs", "joint.min.js"]); - const customJsUri = getUri(["js", "schema-visualizer.js"]); - const customCssUri = getUri(["css", "schema-visualizer.css"]); - - return ` - - - - - - StepZen Schema Visualizer - -