Skip to content

Commit 8c166ba

Browse files
committed
Implement Class Documentation Preview Webviews
1 parent 10aa9f8 commit 8c166ba

File tree

8 files changed

+490
-2
lines changed

8 files changed

+490
-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: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
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(this.viewType, `Preview ${clsname}.cls`, vscode.ViewColumn.Beside, {
77+
enableScripts: true,
78+
enableCommandUris: true,
79+
localResourceRoots: [webviewFolderUri],
80+
});
81+
82+
this.currentPanel = new DocumaticPreviewPanel(panel, webviewFolderUri, openEditor);
83+
}
84+
85+
private constructor(panel: vscode.WebviewPanel, webviewFolderUri: vscode.Uri, editor: vscode.TextEditor) {
86+
this._panel = panel;
87+
this._webviewFolderUri = webviewFolderUri;
88+
this._editor = editor;
89+
90+
// Update the panel's icon
91+
this._panel.iconPath = {
92+
dark: vscode.Uri.joinPath(webviewFolderUri, "preview-dark.svg"),
93+
light: vscode.Uri.joinPath(webviewFolderUri, "preview-light.svg"),
94+
};
95+
96+
// Set the webview's initial content
97+
this.setWebviewHtml();
98+
99+
// Register handlers
100+
this.registerEventHandlers();
101+
102+
// Execute the DocumentSymbolProvider
103+
vscode.commands
104+
.executeCommand<vscode.DocumentSymbol[]>("vscode.executeDocumentSymbolProvider", this._editor.document.uri)
105+
.then((symbols) => {
106+
this._rootSymbol = symbols[0];
107+
this._symbolVersion = this._editor.document.version;
108+
109+
// Send the initial message to the webview
110+
this._panel.webview.postMessage(this.createMessage());
111+
});
112+
}
113+
114+
/**
115+
* Set the static html for the webview.
116+
*/
117+
private setWebviewHtml() {
118+
const webview = this._panel.webview;
119+
120+
// Local path to script and css for the webview
121+
const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(this._webviewFolderUri, "documaticPreview.js"));
122+
const styleUri = webview.asWebviewUri(vscode.Uri.joinPath(this._webviewFolderUri, "documaticPreview.css"));
123+
124+
// Use a nonce to whitelist which scripts can be run
125+
const nonce = (function () {
126+
let text = "";
127+
const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
128+
for (let i = 0; i < 32; i++) {
129+
text += possible.charAt(Math.floor(Math.random() * possible.length));
130+
}
131+
return text;
132+
})();
133+
134+
// Set the webview's html
135+
this._panel.webview.html = `
136+
<!DOCTYPE html>
137+
<html lang="en-us">
138+
<head>
139+
<meta charset="UTF-8">
140+
141+
<!--
142+
Use a content security policy to only allow loading images from https or from our extension directory,
143+
and only allow scripts that have a specific nonce.
144+
-->
145+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${webview.cspSource}; style-src ${webview.cspSource}; script-src 'nonce-${nonce}';">
146+
147+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
148+
149+
<link href="${styleUri}" rel="stylesheet">
150+
</head>
151+
<body>
152+
<br>
153+
<h2 id="header"></h2>
154+
<br>
155+
<div id="showText"></div>
156+
157+
<script nonce="${nonce}" src="${scriptUri}"></script>
158+
</body>
159+
</html>`;
160+
}
161+
162+
/**
163+
* Clean up disposables.
164+
*/
165+
public dispose(): void {
166+
DocumaticPreviewPanel.currentPanel = undefined;
167+
168+
// Clean up our resources
169+
this._panel.dispose();
170+
171+
while (this._disposables.length) {
172+
const disp = this._disposables.pop();
173+
if (disp) {
174+
disp.dispose();
175+
}
176+
}
177+
}
178+
179+
/**
180+
* Create the message to send to the webview.
181+
*/
182+
private createMessage(): WebviewMessage {
183+
// Determine which class definition element the cursor is in
184+
const descLines: string[] = [];
185+
let previewSymbol = this._rootSymbol.children.find((symbol) =>
186+
symbol.range.contains(this._editor.selection.active)
187+
);
188+
if (previewSymbol !== undefined) {
189+
// Get the description text for the class member symbol
190+
for (let line = previewSymbol.range.start.line; line < previewSymbol.selectionRange.start.line; line++) {
191+
const linetext = this._editor.document.lineAt(line).text;
192+
if (linetext.startsWith("/// ")) {
193+
descLines.push(linetext.slice(4));
194+
} else {
195+
descLines.push(linetext.slice(3));
196+
}
197+
}
198+
} else {
199+
// The cursor isn't in a member, so fall back to the class
200+
previewSymbol = this._rootSymbol;
201+
202+
// Get the description text for the class
203+
for (let line = previewSymbol.range.start.line - 1; line >= 0; line--) {
204+
const linetext = this._editor.document.lineAt(line).text;
205+
if (linetext.startsWith("/// ")) {
206+
descLines.push(linetext.slice(4));
207+
} else if (linetext.startsWith("///")) {
208+
descLines.push(linetext.slice(3));
209+
} else {
210+
break;
211+
}
212+
}
213+
descLines.reverse();
214+
}
215+
216+
// Create the message
217+
return {
218+
element: `${previewSymbol.detail !== "" ? previewSymbol.detail : "Class"} ${previewSymbol.name}`,
219+
desc: descLines.join("\n"),
220+
uri: this._editor.document.uri.toString(),
221+
};
222+
}
223+
224+
/**
225+
* Register handlers for events that may cause us to update our preview content
226+
*/
227+
private registerEventHandlers() {
228+
this._panel.onDidDispose(() => this.dispose(), null, this._disposables);
229+
230+
vscode.window.onDidChangeActiveTextEditor(
231+
async (editor: vscode.TextEditor) => {
232+
if (editor !== undefined && editor.document.languageId === "objectscript-class") {
233+
// The new active editor is a class, so switch our preview to it
234+
235+
// Get the name of the current class
236+
let clsname = "";
237+
const match = editor.document.getText().match(/^[ \t]*Class[ \t]+(%?[\p{L}\d]+(?:\.[\p{L}\d]+)+)/imu);
238+
if (match) {
239+
[, clsname] = match;
240+
}
241+
if (clsname === "") {
242+
// The class is malformed so we can't preview it
243+
return;
244+
}
245+
246+
// Update the editor and panel title
247+
this._editor = editor;
248+
this._panel.title = `Preview ${clsname}.cls`;
249+
250+
// Update the root DocumentSymbol
251+
this._rootSymbol = (
252+
await vscode.commands.executeCommand<vscode.DocumentSymbol[]>(
253+
"vscode.executeDocumentSymbolProvider",
254+
this._editor.document.uri
255+
)
256+
)[0];
257+
this._symbolVersion = this._editor.document.version;
258+
259+
// Update the webview content
260+
this._panel.webview.postMessage(this.createMessage());
261+
}
262+
},
263+
null,
264+
this._disposables
265+
);
266+
267+
vscode.window.onDidChangeTextEditorSelection(
268+
async (event: vscode.TextEditorSelectionChangeEvent) => {
269+
if (event.textEditor == this._editor) {
270+
// The cursor position in our editor changed, so re-compute our preview content
271+
if (this._editor.document.version > this._symbolVersion) {
272+
// The content of the TextDocument changed, so update the root DocumentSymbol
273+
this._rootSymbol = (
274+
await vscode.commands.executeCommand<vscode.DocumentSymbol[]>(
275+
"vscode.executeDocumentSymbolProvider",
276+
this._editor.document.uri
277+
)
278+
)[0];
279+
this._symbolVersion = this._editor.document.version;
280+
}
281+
282+
// Update the webview content
283+
this._panel.webview.postMessage(this.createMessage());
284+
}
285+
},
286+
null,
287+
this._disposables
288+
);
289+
}
290+
}

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)