Skip to content

Implement Show Plan CodeLens #1503

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Mar 10, 2025
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
9 changes: 5 additions & 4 deletions .vscodeignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
**
!dist/*.js
!dist/*.txt
!snippets/
!images/
!syntaxes/
!webview/
!snippets/*.json
!images/*.svg
!images/*.png
!syntaxes/*.json
!webview/*.js
!CHANGELOG.md
!LICENSE
!README.md
Expand Down
37 changes: 20 additions & 17 deletions src/commands/documaticPreviewPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export class DocumaticPreviewPanel {
*/
public static currentPanel: DocumaticPreviewPanel | undefined;

public static create(extensionUri: vscode.Uri): void {
public static create(): void {
// Get the open document and check that it's an ObjectScript class
const openEditor = vscode.window.activeTextEditor;
if (openEditor === undefined) {
Expand Down Expand Up @@ -70,9 +70,6 @@ export class DocumaticPreviewPanel {
return;
}

// Get the full path to the folder containing our webview files
const webviewFolderUri: vscode.Uri = vscode.Uri.joinPath(extensionUri, "webview");

// Create the documatic preview webview
const panel = vscode.window.createWebviewPanel(
this.viewType,
Expand All @@ -81,20 +78,20 @@ export class DocumaticPreviewPanel {
{
enableScripts: true,
enableCommandUris: true,
localResourceRoots: [webviewFolderUri],
localResourceRoots: [],
}
);
panel.iconPath = iscIcon;

this.currentPanel = new DocumaticPreviewPanel(panel, webviewFolderUri, openEditor);
this.currentPanel = new DocumaticPreviewPanel(panel, openEditor);
}

private constructor(panel: vscode.WebviewPanel, webviewFolderUri: vscode.Uri, editor: vscode.TextEditor) {
private constructor(panel: vscode.WebviewPanel, editor: vscode.TextEditor) {
this._panel = panel;
this._editor = editor;

// Set the webview's initial content
this.setWebviewHtml(webviewFolderUri);
this.setWebviewHtml();

// Register handlers
this.registerEventHandlers();
Expand All @@ -114,20 +111,28 @@ export class DocumaticPreviewPanel {
/**
* Set the static html for the webview.
*/
private setWebviewHtml(webviewFolderUri: vscode.Uri) {
private setWebviewHtml() {
// Set the webview's html
this._panel.webview.html = `
<!DOCTYPE html>
<html lang="en-us">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script type="module" src="${this._panel.webview.asWebviewUri(
vscode.Uri.joinPath(webviewFolderUri, "elements-1.6.3.js")
)}"></script>
<style>
div.code-block {
background-color: var(--vscode-textCodeBlock-background);
border-radius: 5px;
font-family: monospace;
white-space: pre;
padding: 10px;
padding-top: initial;
overflow-x: scroll;
}
</style>
</head>
<body>
<h1 id="header"></h1>
<h2 id="header"></h2>
<vscode-divider></vscode-divider>
<div id="showText"></div>
<script>
Expand Down Expand Up @@ -175,10 +180,8 @@ export class DocumaticPreviewPanel {
showText.innerHTML = modifiedDesc
.replace(/<class>|<parameter>/gi, "<b><i>")
.replace(/<\\/class>|<\\/parameter>/gi, "</i></b>")
.replace(/<pre>/gi, "<code><pre>")
.replace(/<\\/pre>/gi, "</pre></code>")
.replace(/<example(?: +language *= *"?[a-z]+"?)? *>/gi, "<br/><code><pre>")
.replace(/<\\/example>/gi, "</pre></code>");
.replace(/<example(?: +language *= *"?[a-z]+"?)? *>/gi, "<br/><div class=\\"code-block\\">")
.replace(/<\\/example>/gi, "</div><br/>");

// Then persist state information.
// This state is returned in the call to vscode.getState below when a webview is reloaded.
Expand Down
2 changes: 1 addition & 1 deletion src/commands/restDebugPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,7 @@ export class RESTDebugPanel {
headers["content-type"] = "text/plain; charset=utf-8";
break;
case "HTML":
headers["content-yype"] = "text/html; charset=utf-8";
headers["content-type"] = "text/html; charset=utf-8";
break;
}
}
Expand Down
261 changes: 261 additions & 0 deletions src/commands/showPlanPanel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
import * as vscode from "vscode";
import { DOMParser } from "@xmldom/xmldom";
import { lt } from "semver";
import { AtelierAPI } from "../api";
import { handleError } from "../utils";
import { iscIcon } from "../extension";

const viewType = "isc-show-plan";
const viewTitle = "Show Plan";

let panel: vscode.WebviewPanel;

/** Escape any HTML characters so they are rendered literally */
function htmlEncode(str: string): string {
return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}

/** Convert a block of text (for example, the plan) to HTML */
function formatTextBlock(text: string): string {
let newText = "<p>\n";
let prevIndent = 0;
let ulLevel = 0;
for (const line of text.split(/\r?\n/)) {
let lineTrim = htmlEncode(line.trim());
if (!lineTrim.length) continue; // Line is only whitespace
// Render references to modules or subqueries in the same color as the headers
// for those sections to help users visually draw the link between them
if (lineTrim.includes(" module ") || lineTrim.includes("subquery ") || lineTrim.includes("subqueries ")) {
lineTrim = lineTrim
.replace(/(Call|in) (module [A-Z]|\d{1,5})/g, '$1 <span class="module">$2</span>')
.replace(/subquery [A-Z]|\d{1,5}/g, '<span class="subquery">$&</span>')
.replace(/subqueries (?:[A-Z]|\d{1,5})(?:, [A-Z]|\d{1,5})*,? and [A-Z]|\d{1,5}/g, (match: string): string =>
match
.replace(/subqueries [A-Z]|\d{1,5}/, '<span class="subquery">$&</span>')
.replace(/(,|and) ([A-Z]|\d{1,5})/g, '$1 <span class="subquery">$2</span>')
);
}
const indent = line.search(/\S/) - 1;
if (indent == 0) {
const oldUlLevel = ulLevel;
while (ulLevel) {
newText += "</ul>\n";
if (ulLevel > 1) newText += "</li>\n";
ulLevel--;
}
if (oldUlLevel) newText += "</p>\n<p>\n";
newText += `${lineTrim}<br/>\n`;
} else {
if (indent > prevIndent) {
if (ulLevel) {
newText = `${newText.slice(0, -6)}\n<ul>\n`;
} else {
newText += "<ul>\n";
}
ulLevel++;
} else if (indent < prevIndent) {
newText += `</ul>\n</li>\n`;
}
newText += `<li>${lineTrim}</li>\n`;
}
prevIndent = indent;
}
while (ulLevel) {
newText += "</ul>\n";
if (ulLevel > 1) newText += "</li>\n";
ulLevel--;
}
return `${newText}</p>\n`;
}

/** Create a `Show Plan` Webview, or replace the contents of the one that already exists */
export async function showPlanWebview(args: {
uri: vscode.Uri;
sqlQuery: string;
selectMode: string;
includes: string[];
imports: string[];
className?: string;
}): Promise<void> {
const api = new AtelierAPI(args.uri);
if (!api.active) {
vscode.window.showErrorMessage("Show Plan requires an active server connection.", "Dismiss");
return;
}
if (lt(api.config.serverVersion, "2024.1.0")) {
vscode.window.showErrorMessage("Show Plan requires InterSystems IRIS version 2024.1 or above.", "Dismiss");
return;
}
if (args.className) {
// Query %Dictionary.CompiledClass for a list of all Includes and Imports
await api
.actionQuery(
"SELECT $LISTTOSTRING(Importall) AS Imports, $LISTTOSTRING(IncludeCodeall) AS Includes FROM %Dictionary.CompiledClass WHERE Name = ?",
[args.className]
)
.then((data) => {
if (!data?.result?.content?.length) return;
const row = data.result.content.pop();
if (row.Imports) {
args.imports.push(...row.Imports.replace(/[^\x20-\x7E]/g, "").split(","));
}
if (row.Includes) {
args.includes.push(...row.Includes.replace(/[^\x20-\x7E]/g, "").split(","));
}
})
.catch(() => {
// Swallow errors and try with the info that was in the document
});
}
// Get the plan in XML format
const planXML: string = await api
.actionQuery("SELECT %SYSTEM.QUERY_PLAN(?,,,,,?) XML", [
args.sqlQuery.trimEnd(),
`{"selectmode":"${args.selectMode}"${args.imports.length ? `,"packages":"$LFS(\\"${[...new Set(args.imports)].join(",")}\\")"` : ""}${args.includes.length ? `,"includeFiles":"$LFS(\\"${[...new Set(args.includes)].join(",")}\\")"` : ""}}`,
])
.then((data) => data?.result?.content[0]?.XML)
.catch((error) => {
handleError(error, "Failed to fetch query plan.");
});
if (!planXML) return;
// Convert the XML into HTML
let planHTML = "";
try {
// Parse the XML into a Document object
const xmlDoc = new DOMParser().parseFromString(planXML, "text/xml");
// Get the single <plan> Element, which contains everything else
const planElem = xmlDoc.getElementsByTagName("plan").item(0);

// Loop through the child elements of the plan
let capturePlan = false;
let planText = "";
let planChild = <Element>planElem.firstChild;
while (planChild) {
switch (planChild.nodeName) {
case "sql":
planHTML += '<h3>Statement Text</h3>\n<div class="code-block">\n';
for (const line of planChild.textContent.trim().split(/\r?\n/)) {
planHTML += `${htmlEncode(line.trim())}\n`;
}
planHTML += `</div>\n<hr class="vscode-divider">\n`;
break;
case "warning":
planHTML += `<h3 class="warning-h">Warning</h3>\n<p>\n${formatTextBlock(planChild.textContent)}</p>\n<hr class="vscode-divider">\n`;
break;
case "info":
planHTML += `<h3 class="info-h">Information</h3>\n${formatTextBlock(planChild.textContent)}<hr class="vscode-divider">\n`;
break;
case "cost":
planHTML += `<h4>Relative Cost `;
// The plan might not have a cost
planHTML +=
planChild.attributes.length &&
planChild.attributes.item(0).nodeName == "value" &&
+planChild.attributes.item(0).value
? `= ${planChild.attributes.item(0).value}`
: "Unavailable";
planHTML += "</h4>\n";
capturePlan = true;
break;
case "#text":
if (capturePlan) {
planText += planChild.textContent;
if (!planChild.nextSibling || planChild.nextSibling.nodeName != "#text") {
// This is the end of the plan text, so convert the text to HTML
planHTML += `${formatTextBlock(planText)}<hr class="vscode-divider">\n`;
capturePlan = false;
}
}
break;
case "module": {
let moduleText = "";
let moduleChild = planChild.firstChild;
while (moduleChild) {
moduleText += moduleChild.textContent;
moduleChild = moduleChild.nextSibling;
}
planHTML += `<h3 class="module">Module ${planChild.attributes.item(0).value}</h3>\n${formatTextBlock(moduleText)}<hr class="vscode-divider">\n`;
break;
}
case "subquery": {
let subqueryText = "";
let subqueryChild = planChild.firstChild;
while (subqueryChild) {
subqueryText += subqueryChild.textContent;
subqueryChild = subqueryChild.nextSibling;
}
planHTML += `<h3 class="subquery">Subquery ${planChild.attributes.item(0).value}</h3>\n${formatTextBlock(subqueryText)}<hr class="vscode-divider">\n`;
break;
}
}
planChild = <Element>planChild.nextSibling;
}
// Remove the last divider
planHTML = planHTML.slice(0, -28);
} catch (error) {
handleError(error, "Failed to convert query plan to HTML.");
return;
}

// If a ShowPlan panel exists, replace the content instead of the panel
if (!panel) {
// Create the webview panel
panel = vscode.window.createWebviewPanel(
viewType,
viewTitle,
{ preserveFocus: false, viewColumn: vscode.ViewColumn.Beside },
{
localResourceRoots: [],
}
);
panel.onDidDispose(() => (panel = undefined));
panel.iconPath = iscIcon;
} else if (!panel.visible) {
// Make the panel visible
panel.reveal(vscode.ViewColumn.Beside, false);
}
// Set the HTML content
panel.webview.html = `
<!DOCTYPE html>
<html lang="en-us">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${viewTitle}</title>
<style>
.vscode-divider {
background-color: var(--vscode-widget-border);
border: 0;
display: block;
height: 1px;
margin-bottom: 10px;
margin-top: 10px;
}
.warning-h {
color: var(--vscode-terminal-ansiYellow);
}
.info-h {
color: var(--vscode-terminal-ansiBlue);
}
.module {
color: var(--vscode-terminal-ansiMagenta);
}
.subquery {
color: var(--vscode-terminal-ansiGreen);
}
div.code-block {
background-color: var(--vscode-textCodeBlock-background);
border-radius: 5px;
font-family: monospace;
white-space: pre;
padding: 10px;
padding-top: initial;
overflow-x: scroll;
}
</style>
</head>
<body>
${planHTML}
</body>
</html>`;
}
Loading