Skip to content

Commit aa4a038

Browse files
authored
Implement Show Plan CodeLens (#1503)
1 parent a56bc2f commit aa4a038

8 files changed

+735
-197
lines changed

.vscodeignore

+5-4
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33
**
44
!dist/*.js
55
!dist/*.txt
6-
!snippets/
7-
!images/
8-
!syntaxes/
9-
!webview/
6+
!snippets/*.json
7+
!images/*.svg
8+
!images/*.png
9+
!syntaxes/*.json
10+
!webview/*.js
1011
!CHANGELOG.md
1112
!LICENSE
1213
!README.md

src/commands/documaticPreviewPanel.ts

+20-17
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export class DocumaticPreviewPanel {
3838
*/
3939
public static currentPanel: DocumaticPreviewPanel | undefined;
4040

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

73-
// Get the full path to the folder containing our webview files
74-
const webviewFolderUri: vscode.Uri = vscode.Uri.joinPath(extensionUri, "webview");
75-
7673
// Create the documatic preview webview
7774
const panel = vscode.window.createWebviewPanel(
7875
this.viewType,
@@ -81,20 +78,20 @@ export class DocumaticPreviewPanel {
8178
{
8279
enableScripts: true,
8380
enableCommandUris: true,
84-
localResourceRoots: [webviewFolderUri],
81+
localResourceRoots: [],
8582
}
8683
);
8784
panel.iconPath = iscIcon;
8885

89-
this.currentPanel = new DocumaticPreviewPanel(panel, webviewFolderUri, openEditor);
86+
this.currentPanel = new DocumaticPreviewPanel(panel, openEditor);
9087
}
9188

92-
private constructor(panel: vscode.WebviewPanel, webviewFolderUri: vscode.Uri, editor: vscode.TextEditor) {
89+
private constructor(panel: vscode.WebviewPanel, editor: vscode.TextEditor) {
9390
this._panel = panel;
9491
this._editor = editor;
9592

9693
// Set the webview's initial content
97-
this.setWebviewHtml(webviewFolderUri);
94+
this.setWebviewHtml();
9895

9996
// Register handlers
10097
this.registerEventHandlers();
@@ -114,20 +111,28 @@ export class DocumaticPreviewPanel {
114111
/**
115112
* Set the static html for the webview.
116113
*/
117-
private setWebviewHtml(webviewFolderUri: vscode.Uri) {
114+
private setWebviewHtml() {
118115
// Set the webview's html
119116
this._panel.webview.html = `
120117
<!DOCTYPE html>
121118
<html lang="en-us">
122119
<head>
123120
<meta charset="UTF-8">
124121
<meta name="viewport" content="width=device-width, initial-scale=1.0">
125-
<script type="module" src="${this._panel.webview.asWebviewUri(
126-
vscode.Uri.joinPath(webviewFolderUri, "elements-1.6.3.js")
127-
)}"></script>
122+
<style>
123+
div.code-block {
124+
background-color: var(--vscode-textCodeBlock-background);
125+
border-radius: 5px;
126+
font-family: monospace;
127+
white-space: pre;
128+
padding: 10px;
129+
padding-top: initial;
130+
overflow-x: scroll;
131+
}
132+
</style>
128133
</head>
129134
<body>
130-
<h1 id="header"></h1>
135+
<h2 id="header"></h2>
131136
<vscode-divider></vscode-divider>
132137
<div id="showText"></div>
133138
<script>
@@ -175,10 +180,8 @@ export class DocumaticPreviewPanel {
175180
showText.innerHTML = modifiedDesc
176181
.replace(/<class>|<parameter>/gi, "<b><i>")
177182
.replace(/<\\/class>|<\\/parameter>/gi, "</i></b>")
178-
.replace(/<pre>/gi, "<code><pre>")
179-
.replace(/<\\/pre>/gi, "</pre></code>")
180-
.replace(/<example(?: +language *= *"?[a-z]+"?)? *>/gi, "<br/><code><pre>")
181-
.replace(/<\\/example>/gi, "</pre></code>");
183+
.replace(/<example(?: +language *= *"?[a-z]+"?)? *>/gi, "<br/><div class=\\"code-block\\">")
184+
.replace(/<\\/example>/gi, "</div><br/>");
182185
183186
// Then persist state information.
184187
// This state is returned in the call to vscode.getState below when a webview is reloaded.

src/commands/restDebugPanel.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,7 @@ export class RESTDebugPanel {
346346
headers["content-type"] = "text/plain; charset=utf-8";
347347
break;
348348
case "HTML":
349-
headers["content-yype"] = "text/html; charset=utf-8";
349+
headers["content-type"] = "text/html; charset=utf-8";
350350
break;
351351
}
352352
}

src/commands/showPlanPanel.ts

+261
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
import * as vscode from "vscode";
2+
import { DOMParser } from "@xmldom/xmldom";
3+
import { lt } from "semver";
4+
import { AtelierAPI } from "../api";
5+
import { handleError } from "../utils";
6+
import { iscIcon } from "../extension";
7+
8+
const viewType = "isc-show-plan";
9+
const viewTitle = "Show Plan";
10+
11+
let panel: vscode.WebviewPanel;
12+
13+
/** Escape any HTML characters so they are rendered literally */
14+
function htmlEncode(str: string): string {
15+
return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
16+
}
17+
18+
/** Convert a block of text (for example, the plan) to HTML */
19+
function formatTextBlock(text: string): string {
20+
let newText = "<p>\n";
21+
let prevIndent = 0;
22+
let ulLevel = 0;
23+
for (const line of text.split(/\r?\n/)) {
24+
let lineTrim = htmlEncode(line.trim());
25+
if (!lineTrim.length) continue; // Line is only whitespace
26+
// Render references to modules or subqueries in the same color as the headers
27+
// for those sections to help users visually draw the link between them
28+
if (lineTrim.includes(" module ") || lineTrim.includes("subquery ") || lineTrim.includes("subqueries ")) {
29+
lineTrim = lineTrim
30+
.replace(/(Call|in) (module [A-Z]|\d{1,5})/g, '$1 <span class="module">$2</span>')
31+
.replace(/subquery [A-Z]|\d{1,5}/g, '<span class="subquery">$&</span>')
32+
.replace(/subqueries (?:[A-Z]|\d{1,5})(?:, [A-Z]|\d{1,5})*,? and [A-Z]|\d{1,5}/g, (match: string): string =>
33+
match
34+
.replace(/subqueries [A-Z]|\d{1,5}/, '<span class="subquery">$&</span>')
35+
.replace(/(,|and) ([A-Z]|\d{1,5})/g, '$1 <span class="subquery">$2</span>')
36+
);
37+
}
38+
const indent = line.search(/\S/) - 1;
39+
if (indent == 0) {
40+
const oldUlLevel = ulLevel;
41+
while (ulLevel) {
42+
newText += "</ul>\n";
43+
if (ulLevel > 1) newText += "</li>\n";
44+
ulLevel--;
45+
}
46+
if (oldUlLevel) newText += "</p>\n<p>\n";
47+
newText += `${lineTrim}<br/>\n`;
48+
} else {
49+
if (indent > prevIndent) {
50+
if (ulLevel) {
51+
newText = `${newText.slice(0, -6)}\n<ul>\n`;
52+
} else {
53+
newText += "<ul>\n";
54+
}
55+
ulLevel++;
56+
} else if (indent < prevIndent) {
57+
newText += `</ul>\n</li>\n`;
58+
}
59+
newText += `<li>${lineTrim}</li>\n`;
60+
}
61+
prevIndent = indent;
62+
}
63+
while (ulLevel) {
64+
newText += "</ul>\n";
65+
if (ulLevel > 1) newText += "</li>\n";
66+
ulLevel--;
67+
}
68+
return `${newText}</p>\n`;
69+
}
70+
71+
/** Create a `Show Plan` Webview, or replace the contents of the one that already exists */
72+
export async function showPlanWebview(args: {
73+
uri: vscode.Uri;
74+
sqlQuery: string;
75+
selectMode: string;
76+
includes: string[];
77+
imports: string[];
78+
className?: string;
79+
}): Promise<void> {
80+
const api = new AtelierAPI(args.uri);
81+
if (!api.active) {
82+
vscode.window.showErrorMessage("Show Plan requires an active server connection.", "Dismiss");
83+
return;
84+
}
85+
if (lt(api.config.serverVersion, "2024.1.0")) {
86+
vscode.window.showErrorMessage("Show Plan requires InterSystems IRIS version 2024.1 or above.", "Dismiss");
87+
return;
88+
}
89+
if (args.className) {
90+
// Query %Dictionary.CompiledClass for a list of all Includes and Imports
91+
await api
92+
.actionQuery(
93+
"SELECT $LISTTOSTRING(Importall) AS Imports, $LISTTOSTRING(IncludeCodeall) AS Includes FROM %Dictionary.CompiledClass WHERE Name = ?",
94+
[args.className]
95+
)
96+
.then((data) => {
97+
if (!data?.result?.content?.length) return;
98+
const row = data.result.content.pop();
99+
if (row.Imports) {
100+
args.imports.push(...row.Imports.replace(/[^\x20-\x7E]/g, "").split(","));
101+
}
102+
if (row.Includes) {
103+
args.includes.push(...row.Includes.replace(/[^\x20-\x7E]/g, "").split(","));
104+
}
105+
})
106+
.catch(() => {
107+
// Swallow errors and try with the info that was in the document
108+
});
109+
}
110+
// Get the plan in XML format
111+
const planXML: string = await api
112+
.actionQuery("SELECT %SYSTEM.QUERY_PLAN(?,,,,,?) XML", [
113+
args.sqlQuery.trimEnd(),
114+
`{"selectmode":"${args.selectMode}"${args.imports.length ? `,"packages":"$LFS(\\"${[...new Set(args.imports)].join(",")}\\")"` : ""}${args.includes.length ? `,"includeFiles":"$LFS(\\"${[...new Set(args.includes)].join(",")}\\")"` : ""}}`,
115+
])
116+
.then((data) => data?.result?.content[0]?.XML)
117+
.catch((error) => {
118+
handleError(error, "Failed to fetch query plan.");
119+
});
120+
if (!planXML) return;
121+
// Convert the XML into HTML
122+
let planHTML = "";
123+
try {
124+
// Parse the XML into a Document object
125+
const xmlDoc = new DOMParser().parseFromString(planXML, "text/xml");
126+
// Get the single <plan> Element, which contains everything else
127+
const planElem = xmlDoc.getElementsByTagName("plan").item(0);
128+
129+
// Loop through the child elements of the plan
130+
let capturePlan = false;
131+
let planText = "";
132+
let planChild = <Element>planElem.firstChild;
133+
while (planChild) {
134+
switch (planChild.nodeName) {
135+
case "sql":
136+
planHTML += '<h3>Statement Text</h3>\n<div class="code-block">\n';
137+
for (const line of planChild.textContent.trim().split(/\r?\n/)) {
138+
planHTML += `${htmlEncode(line.trim())}\n`;
139+
}
140+
planHTML += `</div>\n<hr class="vscode-divider">\n`;
141+
break;
142+
case "warning":
143+
planHTML += `<h3 class="warning-h">Warning</h3>\n<p>\n${formatTextBlock(planChild.textContent)}</p>\n<hr class="vscode-divider">\n`;
144+
break;
145+
case "info":
146+
planHTML += `<h3 class="info-h">Information</h3>\n${formatTextBlock(planChild.textContent)}<hr class="vscode-divider">\n`;
147+
break;
148+
case "cost":
149+
planHTML += `<h4>Relative Cost `;
150+
// The plan might not have a cost
151+
planHTML +=
152+
planChild.attributes.length &&
153+
planChild.attributes.item(0).nodeName == "value" &&
154+
+planChild.attributes.item(0).value
155+
? `= ${planChild.attributes.item(0).value}`
156+
: "Unavailable";
157+
planHTML += "</h4>\n";
158+
capturePlan = true;
159+
break;
160+
case "#text":
161+
if (capturePlan) {
162+
planText += planChild.textContent;
163+
if (!planChild.nextSibling || planChild.nextSibling.nodeName != "#text") {
164+
// This is the end of the plan text, so convert the text to HTML
165+
planHTML += `${formatTextBlock(planText)}<hr class="vscode-divider">\n`;
166+
capturePlan = false;
167+
}
168+
}
169+
break;
170+
case "module": {
171+
let moduleText = "";
172+
let moduleChild = planChild.firstChild;
173+
while (moduleChild) {
174+
moduleText += moduleChild.textContent;
175+
moduleChild = moduleChild.nextSibling;
176+
}
177+
planHTML += `<h3 class="module">Module ${planChild.attributes.item(0).value}</h3>\n${formatTextBlock(moduleText)}<hr class="vscode-divider">\n`;
178+
break;
179+
}
180+
case "subquery": {
181+
let subqueryText = "";
182+
let subqueryChild = planChild.firstChild;
183+
while (subqueryChild) {
184+
subqueryText += subqueryChild.textContent;
185+
subqueryChild = subqueryChild.nextSibling;
186+
}
187+
planHTML += `<h3 class="subquery">Subquery ${planChild.attributes.item(0).value}</h3>\n${formatTextBlock(subqueryText)}<hr class="vscode-divider">\n`;
188+
break;
189+
}
190+
}
191+
planChild = <Element>planChild.nextSibling;
192+
}
193+
// Remove the last divider
194+
planHTML = planHTML.slice(0, -28);
195+
} catch (error) {
196+
handleError(error, "Failed to convert query plan to HTML.");
197+
return;
198+
}
199+
200+
// If a ShowPlan panel exists, replace the content instead of the panel
201+
if (!panel) {
202+
// Create the webview panel
203+
panel = vscode.window.createWebviewPanel(
204+
viewType,
205+
viewTitle,
206+
{ preserveFocus: false, viewColumn: vscode.ViewColumn.Beside },
207+
{
208+
localResourceRoots: [],
209+
}
210+
);
211+
panel.onDidDispose(() => (panel = undefined));
212+
panel.iconPath = iscIcon;
213+
} else if (!panel.visible) {
214+
// Make the panel visible
215+
panel.reveal(vscode.ViewColumn.Beside, false);
216+
}
217+
// Set the HTML content
218+
panel.webview.html = `
219+
<!DOCTYPE html>
220+
<html lang="en-us">
221+
<head>
222+
<meta charset="UTF-8">
223+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
224+
<title>${viewTitle}</title>
225+
<style>
226+
.vscode-divider {
227+
background-color: var(--vscode-widget-border);
228+
border: 0;
229+
display: block;
230+
height: 1px;
231+
margin-bottom: 10px;
232+
margin-top: 10px;
233+
}
234+
.warning-h {
235+
color: var(--vscode-terminal-ansiYellow);
236+
}
237+
.info-h {
238+
color: var(--vscode-terminal-ansiBlue);
239+
}
240+
.module {
241+
color: var(--vscode-terminal-ansiMagenta);
242+
}
243+
.subquery {
244+
color: var(--vscode-terminal-ansiGreen);
245+
}
246+
div.code-block {
247+
background-color: var(--vscode-textCodeBlock-background);
248+
border-radius: 5px;
249+
font-family: monospace;
250+
white-space: pre;
251+
padding: 10px;
252+
padding-top: initial;
253+
overflow-x: scroll;
254+
}
255+
</style>
256+
</head>
257+
<body>
258+
${planHTML}
259+
</body>
260+
</html>`;
261+
}

0 commit comments

Comments
 (0)