Skip to content

Commit d40a4f4

Browse files
authored
Merge pull request #754 from isc-bsaviano/master
Implement Class Documentation Preview Webviews
2 parents ae6996e + 78ad60f commit d40a4f4

File tree

8 files changed

+495
-2
lines changed

8 files changed

+495
-2
lines changed

package-lock.json

Lines changed: 0 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@
130130
},
131131
{
132132
"command": "vscode-objectscript.previewXml",
133-
"when": "editorLangId = xml && vscode-objectscript.connectActive"
133+
"when": "editorLangId == xml && vscode-objectscript.connectActive"
134134
},
135135
{
136136
"command": "vscode-objectscript.explorer.export",
@@ -223,6 +223,10 @@
223223
{
224224
"command": "vscode-objectscript.editOthers",
225225
"when": "editorLangId =~ /^objectscript/ && vscode-objectscript.connectActive"
226+
},
227+
{
228+
"command": "vscode-objectscript.showClassDocumentationPreview",
229+
"when": "editorLangId == objectscript-class"
226230
}
227231
],
228232
"view/title": [
@@ -335,6 +339,18 @@
335339
"command": "vscode-objectscript.serverCommands.other",
336340
"group": "navigation@2",
337341
"when": "vscode-objectscript.connectActive && resourceScheme =~ /^isfs(-readonly)?$/"
342+
},
343+
{
344+
"command": "vscode-objectscript.showClassDocumentationPreview",
345+
"group": "navigation@3",
346+
"when": "editorLangId == objectscript-class"
347+
}
348+
],
349+
"editor/title/context": [
350+
{
351+
"command": "vscode-objectscript.showClassDocumentationPreview",
352+
"group": "1_open",
353+
"when": "resourceLangId == objectscript-class"
338354
}
339355
],
340356
"touchBar": [
@@ -657,6 +673,12 @@
657673
"category": "ObjectScript",
658674
"command": "vscode-objectscript.editOthers",
659675
"title": "Edit Other"
676+
},
677+
{
678+
"category": "ObjectScript",
679+
"command": "vscode-objectscript.showClassDocumentationPreview",
680+
"title": "Show Class Documentation Preview",
681+
"icon": "$(open-preview)"
660682
}
661683
],
662684
"keybindings": [

src/commands/documaticPreviewPanel.ts

Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
import * as vscode from "vscode";
2+
3+
/**
4+
* The schema of the message that gets sent to the webview.
5+
*/
6+
type WebviewMessage = {
7+
/** The element (class or class member) that we're previewing documentation for. */
8+
element: string;
9+
/** The documentation string for `element`. */
10+
desc: string;
11+
/** The uri of the class that we're previewing documentation for. */
12+
uri: string;
13+
};
14+
15+
/**
16+
* Manages Class Documentation preview webviews.
17+
*/
18+
export class DocumaticPreviewPanel {
19+
/** The viewType for Class Documentation preview webviews. */
20+
private static readonly viewType = "isc-documatic-preview";
21+
22+
private readonly _panel: vscode.WebviewPanel;
23+
private readonly _webviewFolderUri: vscode.Uri;
24+
private _disposables: vscode.Disposable[] = [];
25+
26+
/** The `TextEditor` of the class document that we're previewing documentation for. */
27+
private _editor: vscode.TextEditor;
28+
29+
/** The class definition `DocumentSymbol` for `_editor`. */
30+
private _rootSymbol: vscode.DocumentSymbol;
31+
32+
/** The version of the `TextDocument` associated with `_editor` that `_rootSymbol` was calculated for. */
33+
private _symbolVersion: number;
34+
35+
/**
36+
* Track the currently panel. Only allow a single panel to exist at a time.
37+
*/
38+
public static currentPanel: DocumaticPreviewPanel | undefined;
39+
40+
public static create(extensionUri: vscode.Uri): void {
41+
// Get the open document and check that it's an ObjectScript class
42+
const openEditor = vscode.window.activeTextEditor;
43+
if (openEditor === undefined) {
44+
// Need an open document to preview
45+
return;
46+
}
47+
const openDoc = openEditor.document;
48+
if (openDoc.languageId !== "objectscript-class") {
49+
// Documatic preview is for classes only
50+
return;
51+
}
52+
if (this.currentPanel !== undefined) {
53+
// Can only have one panel open at once
54+
if (!this.currentPanel._panel.visible) {
55+
// The open panel isn't visible, so show it
56+
this.currentPanel._panel.reveal(vscode.ViewColumn.Beside);
57+
}
58+
return;
59+
}
60+
61+
// Get the name of the current class
62+
let clsname = "";
63+
const match = openDoc.getText().match(/^[ \t]*Class[ \t]+(%?[\p{L}\d]+(?:\.[\p{L}\d]+)+)/imu);
64+
if (match) {
65+
[, clsname] = match;
66+
}
67+
if (clsname === "") {
68+
// The class is malformed so we can't preview it
69+
return;
70+
}
71+
72+
// Get the full path to the folder containing our webview files
73+
const webviewFolderUri: vscode.Uri = vscode.Uri.joinPath(extensionUri, "webview");
74+
75+
// Create the documatic preview webview
76+
const panel = vscode.window.createWebviewPanel(
77+
this.viewType,
78+
`Doc for ${clsname}.cls`,
79+
{ preserveFocus: true, viewColumn: vscode.ViewColumn.Beside },
80+
{
81+
enableScripts: true,
82+
enableCommandUris: true,
83+
localResourceRoots: [webviewFolderUri],
84+
}
85+
);
86+
87+
this.currentPanel = new DocumaticPreviewPanel(panel, webviewFolderUri, openEditor);
88+
}
89+
90+
private constructor(panel: vscode.WebviewPanel, webviewFolderUri: vscode.Uri, editor: vscode.TextEditor) {
91+
this._panel = panel;
92+
this._webviewFolderUri = webviewFolderUri;
93+
this._editor = editor;
94+
95+
// Update the panel's icon
96+
this._panel.iconPath = {
97+
dark: vscode.Uri.joinPath(webviewFolderUri, "preview-dark.svg"),
98+
light: vscode.Uri.joinPath(webviewFolderUri, "preview-light.svg"),
99+
};
100+
101+
// Set the webview's initial content
102+
this.setWebviewHtml();
103+
104+
// Register handlers
105+
this.registerEventHandlers();
106+
107+
// Execute the DocumentSymbolProvider
108+
vscode.commands
109+
.executeCommand<vscode.DocumentSymbol[]>("vscode.executeDocumentSymbolProvider", this._editor.document.uri)
110+
.then((symbols) => {
111+
this._rootSymbol = symbols[0];
112+
this._symbolVersion = this._editor.document.version;
113+
114+
// Send the initial message to the webview
115+
this._panel.webview.postMessage(this.createMessage());
116+
});
117+
}
118+
119+
/**
120+
* Set the static html for the webview.
121+
*/
122+
private setWebviewHtml() {
123+
const webview = this._panel.webview;
124+
125+
// Local path to script and css for the webview
126+
const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(this._webviewFolderUri, "documaticPreview.js"));
127+
const styleUri = webview.asWebviewUri(vscode.Uri.joinPath(this._webviewFolderUri, "documaticPreview.css"));
128+
129+
// Use a nonce to whitelist which scripts can be run
130+
const nonce = (function () {
131+
let text = "";
132+
const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
133+
for (let i = 0; i < 32; i++) {
134+
text += possible.charAt(Math.floor(Math.random() * possible.length));
135+
}
136+
return text;
137+
})();
138+
139+
// Set the webview's html
140+
this._panel.webview.html = `
141+
<!DOCTYPE html>
142+
<html lang="en-us">
143+
<head>
144+
<meta charset="UTF-8">
145+
146+
<!--
147+
Use a content security policy to only allow loading images from https or from our extension directory,
148+
and only allow scripts that have a specific nonce.
149+
-->
150+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${webview.cspSource}; style-src ${webview.cspSource}; script-src 'nonce-${nonce}';">
151+
152+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
153+
154+
<link href="${styleUri}" rel="stylesheet">
155+
</head>
156+
<body>
157+
<br>
158+
<h2 id="header"></h2>
159+
<br>
160+
<div id="showText"></div>
161+
162+
<script nonce="${nonce}" src="${scriptUri}"></script>
163+
</body>
164+
</html>`;
165+
}
166+
167+
/**
168+
* Clean up disposables.
169+
*/
170+
public dispose(): void {
171+
DocumaticPreviewPanel.currentPanel = undefined;
172+
173+
// Clean up our resources
174+
this._panel.dispose();
175+
176+
while (this._disposables.length) {
177+
const disp = this._disposables.pop();
178+
if (disp) {
179+
disp.dispose();
180+
}
181+
}
182+
}
183+
184+
/**
185+
* Create the message to send to the webview.
186+
*/
187+
private createMessage(): WebviewMessage {
188+
// Determine which class definition element the cursor is in
189+
const descLines: string[] = [];
190+
let previewSymbol = this._rootSymbol.children.find((symbol) =>
191+
symbol.range.contains(this._editor.selection.active)
192+
);
193+
if (previewSymbol !== undefined) {
194+
// Get the description text for the class member symbol
195+
for (let line = previewSymbol.range.start.line; line < previewSymbol.selectionRange.start.line; line++) {
196+
const linetext = this._editor.document.lineAt(line).text;
197+
if (linetext.startsWith("/// ")) {
198+
descLines.push(linetext.slice(4));
199+
} else {
200+
descLines.push(linetext.slice(3));
201+
}
202+
}
203+
} else {
204+
// The cursor isn't in a member, so fall back to the class
205+
previewSymbol = this._rootSymbol;
206+
207+
// Get the description text for the class
208+
for (let line = previewSymbol.range.start.line - 1; line >= 0; line--) {
209+
const linetext = this._editor.document.lineAt(line).text;
210+
if (linetext.startsWith("/// ")) {
211+
descLines.push(linetext.slice(4));
212+
} else if (linetext.startsWith("///")) {
213+
descLines.push(linetext.slice(3));
214+
} else {
215+
break;
216+
}
217+
}
218+
descLines.reverse();
219+
}
220+
221+
// Create the message
222+
return {
223+
element: `${previewSymbol.detail !== "" ? previewSymbol.detail : "Class"} ${previewSymbol.name}`,
224+
desc: descLines.join("\n"),
225+
uri: this._editor.document.uri.toString(),
226+
};
227+
}
228+
229+
/**
230+
* Register handlers for events that may cause us to update our preview content
231+
*/
232+
private registerEventHandlers() {
233+
this._panel.onDidDispose(() => this.dispose(), null, this._disposables);
234+
235+
vscode.window.onDidChangeActiveTextEditor(
236+
async (editor: vscode.TextEditor) => {
237+
if (editor !== undefined && editor.document.languageId === "objectscript-class") {
238+
// The new active editor is a class, so switch our preview to it
239+
240+
// Get the name of the current class
241+
let clsname = "";
242+
const match = editor.document.getText().match(/^[ \t]*Class[ \t]+(%?[\p{L}\d]+(?:\.[\p{L}\d]+)+)/imu);
243+
if (match) {
244+
[, clsname] = match;
245+
}
246+
if (clsname === "") {
247+
// The class is malformed so we can't preview it
248+
return;
249+
}
250+
251+
// Update the editor and panel title
252+
this._editor = editor;
253+
this._panel.title = `Doc for ${clsname}.cls`;
254+
255+
// Update the root DocumentSymbol
256+
this._rootSymbol = (
257+
await vscode.commands.executeCommand<vscode.DocumentSymbol[]>(
258+
"vscode.executeDocumentSymbolProvider",
259+
this._editor.document.uri
260+
)
261+
)[0];
262+
this._symbolVersion = this._editor.document.version;
263+
264+
// Update the webview content
265+
this._panel.webview.postMessage(this.createMessage());
266+
}
267+
},
268+
null,
269+
this._disposables
270+
);
271+
272+
vscode.window.onDidChangeTextEditorSelection(
273+
async (event: vscode.TextEditorSelectionChangeEvent) => {
274+
if (event.textEditor == this._editor) {
275+
// The cursor position in our editor changed, so re-compute our preview content
276+
if (this._editor.document.version > this._symbolVersion) {
277+
// The content of the TextDocument changed, so update the root DocumentSymbol
278+
this._rootSymbol = (
279+
await vscode.commands.executeCommand<vscode.DocumentSymbol[]>(
280+
"vscode.executeDocumentSymbolProvider",
281+
this._editor.document.uri
282+
)
283+
)[0];
284+
this._symbolVersion = this._editor.document.version;
285+
}
286+
287+
// Update the webview content
288+
this._panel.webview.postMessage(this.createMessage());
289+
}
290+
},
291+
null,
292+
this._disposables
293+
);
294+
}
295+
}

src/extension.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import {
4545
import { addServerNamespaceToWorkspace } from "./commands/addServerNamespaceToWorkspace";
4646
import { jumpToTagAndOffset } from "./commands/jumpToTagAndOffset";
4747
import { connectFolderToServerNamespace } from "./commands/connectFolderToServerNamespace";
48+
import { DocumaticPreviewPanel } from "./commands/documaticPreviewPanel";
4849

4950
import { getLanguageConfiguration } from "./languageConfiguration";
5051

@@ -925,6 +926,9 @@ export async function activate(context: vscode.ExtensionContext): Promise<any> {
925926
new DocumentLinkProvider()
926927
),
927928
vscode.commands.registerCommand("vscode-objectscript.editOthers", () => viewOthers(true)),
929+
vscode.commands.registerCommand("vscode-objectscript.showClassDocumentationPreview", () =>
930+
DocumaticPreviewPanel.create(context.extensionUri)
931+
),
928932

929933
/* Anything we use from the VS Code proposed API */
930934
...proposed

0 commit comments

Comments
 (0)