Multiple webviews in a single Visual Studio Code Extension
In my previous blog post Getting Started with Visual Studio Code Extension Development I described how to create a Visual Studio Code Extension that contributes a custom editor that is implemented using a webview. And I compared how this can be done using vanilla HTML and Javascript, using Angular and using React. Only a single webview is contributed by each Visual Studio Code Extension in the examples of that blog post, which is sufficient for a lot of use cases. But after the publishing of that blog post I was asked several times how to contribute multiple editors with a single extension. It turned out that this is not as intuitive as initially thought, so I decided to write a new blog post dedicated to this topic.
To demonstrate the solutions for the different variants I will add a simple pet editor to the extensions.
Note:
The following content is based on the Getting Started with Visual Studio Code Extension Development blog post. If you want to follow the steps described in this post, work through the previous blog post or get the sources from the getting_started branch.
Visual Studio Code Extension Project
Let’s start by adding a second custom editor to the Vanilla HTML and Javascript Visual Studio Code Extension.
-
Open the vscode-extension/package.json
- Extend the
contributes
section to add a Pet editor for the .pet file extension:
"contributes": { "customEditors": [ { "viewType": "vscode-extension.personEditor", "displayName": "Visual Studio Code Person Editor", "selector": [ { "filenamePattern": "*.person" } ], "priority": "default" }, { "viewType": "vscode-extension.petEditor", "displayName": "Visual Studio Code Pet Editor", "selector": [ { "filenamePattern": "*.pet" } ], "priority": "default" } ] },
- Extend the
-
Create a new file vscode-extension/src/abstractEditor.ts
To avoid that we need to copy a lot of code for the new editor, we create an abstract base classAbstractEditorProvider
that will be used by both editor implementations.- Copy the content of vscode-extension/src/personEditor.ts to vscode-extension/src/abstractEditor.ts
- Rename
PersonEditorProvider
toAbstractEditorProvider
and make itabstract
- Remove the
viewType
constant, theconstructor
and theregister
method - Change the visibility of
getWebviewHtml()
method toprotected
and make itabstract
import * as vscode from "vscode"; export abstract class AbstractEditorProvider implements vscode.CustomTextEditorProvider { public async resolveCustomTextEditor( document: vscode.TextDocument, webviewPanel: vscode.WebviewPanel, _token: vscode.CancellationToken ): Promise<void> { // Setup initial content for the webview webviewPanel.webview.options = { // Enable scripts in the webview enableScripts: true, }; webviewPanel.webview.html = this.getWebviewHtml(webviewPanel.webview); // Hook up event handlers so that we can synchronize the webview with the text document. // // The text document acts as our model, so we have to sync change in the document to our // editor and sync changes in the editor back to the document. // // Remember that a single text document can also be shared between multiple custom // editors (this happens for example when you split a custom editor) const changeDocumentSubscription = vscode.workspace.onDidChangeTextDocument((e) => { if (e.document.uri.toString() === document.uri.toString()) { this.updateWebview(webviewPanel, document); } }); // Make sure we get rid of the listener when our editor is closed. webviewPanel.onDidDispose(() => { changeDocumentSubscription.dispose(); }); // Receive message from the webview. webviewPanel.webview.onDidReceiveMessage((e) => { switch (e.type) { case "updateDocument": this.updateDocument(document, e.text); return; } }); this.updateWebview(webviewPanel, document); } /** * Updates the webview with the content of the current document. * @param webviewPanel The webview panel to post the message to. * @param document The current document. */ private updateWebview( webviewPanel: vscode.WebviewPanel, document: vscode.TextDocument ) { // Post message with text of the document to the webview webviewPanel.webview.postMessage({ type: "update", text: document.getText(), }); } /** * Applies a set of text edits to a document. * @param document The current document. * @param text The text to set to the document. * @returns A thenable that resolves when the edit could be applied. */ private updateDocument(document: vscode.TextDocument, text: string) { const edit = new vscode.WorkspaceEdit(); edit.replace( document.uri, new vscode.Range(0, 0, document.lineCount, 0), text ); return vscode.workspace.applyEdit(edit); } /** * Get the static html used for the editor webviews. */ protected abstract getWebviewHtml(webview: vscode.Webview): string; /** * A helper function that returns a unique alphanumeric identifier called a nonce. * * @remarks This function is primarily used to help enforce content security * policies for resources/scripts being executed in a webview context. * * @returns A nonce */ getNonce() { let text = ""; const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; for (let i = 0; i < 32; i++) { text += possible.charAt(Math.floor(Math.random() * possible.length)); } return text; } }
-
Update the file vscode-extension/src/personEditor.ts
- Extend
AbstractEditorProvider
- Call
super()
in theconstructor
- Change the visibility of
getWebviewHtml()
toprotected
- Delete the other methods that are now defined in
AbstractEditorProvider
import * as vscode from "vscode"; import { AbstractEditorProvider } from "./abstractEditor"; export class PersonEditorProvider extends AbstractEditorProvider { private static readonly viewType = "vscode-extension.personEditor"; constructor(private readonly context: vscode.ExtensionContext) { super(); } public static register( context: vscode.ExtensionContext ): vscode.Disposable { const provider = new PersonEditorProvider(context); const providerRegistration = vscode.window.registerCustomEditorProvider( PersonEditorProvider.viewType, provider ); return providerRegistration; } /** * Get the static html used for the editor webviews. */ protected getWebviewHtml(webview: vscode.Webview): string { // Local path to script and css for the webview const stylesUri = webview.asWebviewUri( vscode.Uri.joinPath(this.context.extensionUri, "media", "styles.css") ); const scriptUri = webview.asWebviewUri( vscode.Uri.joinPath(this.context.extensionUri, "media", "main.js") ); const elementsUri = webview.asWebviewUri( vscode.Uri.joinPath( this.context.extensionUri, "node_modules", "@vscode-elements/elements", "dist", "bundled.js" ) ); const nonce = this.getNonce(); // Tip: Install the es6-string-html Visual Studio Code extension to enable code highlighting below return /* html */ ` <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <!-- Use a content security policy to only allow loading images, styles and fonts from https or from our extension directory, and only allow scripts that have a specific nonce. --> <meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${webview.cspSource}; font-src ${webview.cspSource}; style-src ${webview.cspSource}; script-src 'nonce-${nonce}';"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <link href="${stylesUri}" rel="stylesheet" /> <title>Person Editor</title> </head> <body> <h1>Visual Studio Code Person Editor</h1> <div class="person"> <div class="row"> <vscode-label for="firstname">Firstname:</vscode-label> <div class="value"> <vscode-textfield type="text" id="firstname"/> </div> </div> <div class="row"> <vscode-label for="lastname">Lastname:</vscode-label> <div class="value"> <vscode-textfield type="text" id="lastname"/> </div> </div> </div> <script nonce="${nonce}" src="${scriptUri}"></script> <script nonce="${nonce}" src="${elementsUri}" type="module"></script> </body> </html>`; } }
- Extend
-
Create a new file vscode-extension/src/petEditor.ts
- Copy the content of vscode-extension/src/personEditor.ts
- Rename the class to
PetEditorProvider
- Update the value of
viewType
to"vscode-extension.petEditor"
- Update the
register()
implementation to register thePetEditorProvider
- Update the
getWebviewHtml()
implementation to return the HTML for a pet editor
import * as vscode from "vscode"; import { AbstractEditorProvider } from "./abstractEditor"; export class PetEditorProvider extends AbstractEditorProvider { private static readonly viewType = "vscode-extension.petEditor"; constructor(private readonly context: vscode.ExtensionContext) { super(); } public static register( context: vscode.ExtensionContext ): vscode.Disposable { const provider = new PetEditorProvider(context); const providerRegistration = vscode.window.registerCustomEditorProvider( PetEditorProvider.viewType, provider ); return providerRegistration; } /** * Get the static html used for the editor webviews. */ protected getWebviewHtml(webview: vscode.Webview): string { // Local path to script and css for the webview const stylesUri = webview.asWebviewUri( vscode.Uri.joinPath(this.context.extensionUri, "media", "styles.css") ); const scriptUri = webview.asWebviewUri( vscode.Uri.joinPath(this.context.extensionUri, "media", "main.js") ); const elementsUri = webview.asWebviewUri( vscode.Uri.joinPath( this.context.extensionUri, "node_modules", "@vscode-elements/elements", "dist", "bundled.js" ) ); const nonce = this.getNonce(); // Tip: Install the es6-string-html Visual Studio Code extension to enable code highlighting below return /* html */ ` <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <!-- Use a content security policy to only allow loading images, styles and fonts from https or from our extension directory, and only allow scripts that have a specific nonce. --> <meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${webview.cspSource}; font-src ${webview.cspSource}; style-src ${webview.cspSource}; script-src 'nonce-${nonce}';"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <link href="${stylesUri}" rel="stylesheet" /> <title>Pet Editor</title> </head> <body> <h1>Visual Studio Code Pet Editor</h1> <div class="pet"> <div class="row"> <vscode-label for="name">Name:</vscode-label> <div class="value"> <vscode-textfield type="text" id="name"/> </div> </div> <div class="row"> <vscode-label for="species">Species:</vscode-label> <div class="value"> <vscode-single-select id="species"> <vscode-option>-</vscode-option> <vscode-option description="bird">Bird</vscode-option> <vscode-option description="cat">Cat</vscode-option> <vscode-option description="dog">Dog</vscode-option> </vscode-single-select> </div> </div> </div> <script nonce="${nonce}" src="${scriptUri}"></script> <script nonce="${nonce}" src="${elementsUri}" type="module"></script> </body> </html>`; } }
-
Update the file vscode-extension/media/main.js so the code is able to handle persons and pets
// Script run within the webview itself. (function () { // Get a reference to the Visual Studio Code webview api. // We use this API to post messages back to our extension. const vscode = acquireVsCodeApi(); const personContainer = /** @type {HTMLElement} */ ( document.querySelector(".person") ); const petContainer = /** @type {HTMLElement} */ ( document.querySelector(".pet") ); const errorContainer = document.createElement("div"); document.body.appendChild(errorContainer); errorContainer.className = "error"; errorContainer.style.display = "none"; /** * Render the document in the webview. */ function updateContent(/** @type {string} */ text) { let json; try { if (!text) { text = "{}"; } json = JSON.parse(text); } catch { if (personContainer) { personContainer.style.display = "none"; } if (petContainer) { petContainer.style.display = "none"; } errorContainer.innerText = "Error: Document is not valid json"; errorContainer.style.display = ""; return; } if (personContainer) { personContainer.style.display = ""; } if (petContainer) { petContainer.style.display = ""; } errorContainer.style.display = "none"; const firstname = document.getElementById("firstname"); const lastname = document.getElementById("lastname"); const name = document.getElementById("name"); const species = document.getElementById("species"); if (firstname && lastname) { if (json.firstname) { firstname.value = json.firstname; } if (json.lastname) { lastname.value = json.lastname; } firstname.oninput = () => { let value = firstname.value; // wait 500 ms before updating the document // only update if in the meantime no other input was given setTimeout(() => { if (value === firstname.value) { json.firstname = firstname.value; vscode.postMessage({ type: "updateDocument", text: JSON.stringify(json, null, 2), }); } }, 500); }; lastname.oninput = () => { let value = lastname.value; // wait 500 ms before updating the document // only update if in the meantime no other input was given setTimeout(() => { if (value === lastname.value) { json.lastname = lastname.value; vscode.postMessage({ type: "updateDocument", text: JSON.stringify(json, null, 2), }); } }, 500); }; } if (name && species) { if (json.name) { name.value = json.name; } if (json.species) { species.value = json.species; } name.oninput = () => { let value = name.value; // wait 500 ms before updating the document // only update if in the meantime no other input was given setTimeout(() => { if (value === name.value) { json.name = name.value; vscode.postMessage({ type: "updateDocument", text: JSON.stringify(json, null, 2), }); } }, 500); }; species.oninput = () => { let value = species.value; // wait 500 ms before updating the document // only update if in the meantime no other input was given setTimeout(() => { if (value === species.value) { json.species = species.value; vscode.postMessage({ type: "updateDocument", text: JSON.stringify(json, null, 2), }); } }, 500); }; } } // Handle messages sent from the extension to the webview window.addEventListener("message", (event) => { const message = event.data; // The json data that the extension sent switch (message.type) { case "update": const text = message.text; // Update our webview's content updateContent(text); // Then persist state information. // This state is returned in the call to `vscode.getState` below when a webview is reloaded. vscode.setState({ text }); return; } }); // Webviews are normally torn down when not visible and re-created when they become visible again. // State lets us save information across these re-loads const state = vscode.getState(); if (state) { updateContent(state.text); } })();
-
Update vscode-extension/src/extension.ts
- Register the
PetEditorProvider
// The module 'vscode' contains the Visual Studio Code extensibility API // Import the module and reference it with the alias vscode in your code below import * as vscode from "vscode"; import { PersonEditorProvider } from "./personEditor"; import { PetEditorProvider } from "./petEditor"; // This method is called when your extension is activated export function activate(context: vscode.ExtensionContext) { // Register our custom editor provider context.subscriptions.push(PersonEditorProvider.register(context)); context.subscriptions.push(PetEditorProvider.register(context)); } // This method is called when your extension is deactivated export function deactivate() {}
- Register the
If everything is correctly in place, you can verify the editor by
- pressing F5 to start a new Visual Studio Code instance with the extension
- opening a folder somewhere
Create a folder example in the home directory of the node user in the Dev Container for example. - creating a new file named santas.pet
Now a webview with two input fields should be visible.
- enter values for Name and Species,
e.g. Name: Santa’s little helper, Species: Dog - Save via CTRL + S
- Right click on the created file in the Explorer
- Select Open With… - Text Editor
The default text editor should open with the JSON content of the created file.
Angular Webview Implementation
To provide multiple webviews in an extension that uses Angular as webframework, there is some more work to do.
In the Getting Started with Visual Studio Code Extension Development - Angular Webview Implementation I implemented the webview as a Angular Standalone Component and I removed the RouterOutlet
to make the usage of assets from third-party modules work correctly.
The following section describes how to add a second custom editor to the Angular Visual Studio Code Extension.
-
Open the angular-extension/package.json
- Extend the
contributes
section to add a Pet editor for the .pet file extension:
"contributes": { "customEditors": [ { "viewType": "angular-extension.personEditor", "displayName": "Angular Person Editor", "selector": [ { "filenamePattern": "*.person" } ], "priority": "option" }, { "viewType": "angular-extension.petEditor", "displayName": "Angular Pet Editor", "selector": [ { "filenamePattern": "*.pet" } ], "priority": "option" } ] },
- Extend the
-
Create a new file angular-extension/src/abstractEditor.ts
To avoid that we need to copy a lot of code for the new editor, we create an abstract base classAbstractEditorProvider
that will be used by both editor implementations.- Copy the content of angular-extension/src/personEditor.ts to angular-extension/src/abstractEditor.ts
- Rename
PersonEditorProvider
toAbstractEditorProvider
and make itabstract
- Remove the
viewType
constant and theregister
method - Add an abstract method
getRootComponentSelector()
- Update
getWebviewHtml()
to usegetRootComponentSelector()
as the app root component and as an attributedata-root
in thehtml
tag
import * as vscode from "vscode"; export abstract class AbstractEditorProvider implements vscode.CustomTextEditorProvider { constructor(private readonly context: vscode.ExtensionContext) {} public async resolveCustomTextEditor( document: vscode.TextDocument, webviewPanel: vscode.WebviewPanel, _token: vscode.CancellationToken ): Promise<void> { // Setup initial content for the webview webviewPanel.webview.options = { // Enable scripts in the webview enableScripts: true, // Restrict the webview to only load resources from the `dist` and `webview-ui/build` directories localResourceRoots: [ vscode.Uri.joinPath(this.context.extensionUri, "dist"), vscode.Uri.joinPath(this.context.extensionUri, "webview-ui/build"), ], }; webviewPanel.webview.html = this.getWebviewHtml(webviewPanel.webview); // Hook up event handlers so that we can synchronize the webview with the text document. // // The text document acts as our model, so we have to sync change in the document to our // editor and sync changes in the editor back to the document. // // Remember that a single text document can also be shared between multiple custom // editors (this happens for example when you split a custom editor) const changeDocumentSubscription = vscode.workspace.onDidChangeTextDocument((e) => { if (e.document.uri.toString() === document.uri.toString()) { this.updateWebview(webviewPanel, document); } }); // Make sure we get rid of the listener when our editor is closed. webviewPanel.onDidDispose(() => { changeDocumentSubscription.dispose(); }); // Receive message from the webview. webviewPanel.webview.onDidReceiveMessage((e) => { switch (e.type) { case "updateDocument": this.updateDocument(document, e.text); return; } }); this.updateWebview(webviewPanel, document); } /** * Updates the webview with the content of the current document. * @param webviewPanel The webview panel to post the message to. * @param document The current document. */ private updateWebview( webviewPanel: vscode.WebviewPanel, document: vscode.TextDocument ) { // Post message with text of the document to the webview webviewPanel.webview.postMessage({ type: "update", text: document.getText(), }); } /** * Applies a set of text edits to a document. * @param document The current document. * @param text The text to set to the document. * @returns A thenable that resolves when the edit could be applied. */ private updateDocument(document: vscode.TextDocument, text: string) { const edit = new vscode.WorkspaceEdit(); edit.replace( document.uri, new vscode.Range(0, 0, document.lineCount, 0), text ); return vscode.workspace.applyEdit(edit); } /** * Get the static html used for the editor webviews. */ private getWebviewHtml(webview: vscode.Webview): string { // The CSS file from the Angular build output const stylesUri = webview.asWebviewUri( vscode.Uri.joinPath( this.context.extensionUri, "webview-ui", "build", "styles.css" ) ); // The JS files from the Angular build output const polyfillsUri = webview.asWebviewUri( vscode.Uri.joinPath( this.context.extensionUri, "webview-ui", "build", "polyfills.js" ) ); const scriptUri = webview.asWebviewUri( vscode.Uri.joinPath( this.context.extensionUri, "webview-ui", "build", "main.js" ) ); const rootSelector = this.getRootComponentSelector(); const nonce = this.getNonce(); // Tip: Install the es6-string-html VS Code extension to enable code highlighting below return /*html*/ ` <!DOCTYPE html> <html lang="en" data-root="${rootSelector}"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${webview.cspSource} 'unsafe-inline'; font-src ${webview.cspSource}; img-src ${webview.cspSource}; script-src 'nonce-${nonce}';"> <link rel="stylesheet" type="text/css" href="${stylesUri}"> </head> <body> <${rootSelector}></${rootSelector}> <script type="module" nonce="${nonce}" src="${polyfillsUri}"></script> <script type="module" nonce="${nonce}" src="${scriptUri}"></script> </body> </html> `; } /** * Return the selector of the root component to use. */ abstract getRootComponentSelector(): string; /** * A helper function that returns a unique alphanumeric identifier called a nonce. * * @remarks This function is primarily used to help enforce content security * policies for resources/scripts being executed in a webview context. * * @returns A nonce */ getNonce() { let text = ""; const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; for (let i = 0; i < 32; i++) { text += possible.charAt(Math.floor(Math.random() * possible.length)); } return text; } }
-
Update the file angular-extension/src/personEditor.ts
- Extend
AbstractEditorProvider
- Call
super(context)
in theconstructor
- Add the
getRootComponentSelector()
method which returnsperson-root
- Delete the other methods that are now defined in
AbstractEditorProvider
import * as vscode from "vscode"; import { AbstractEditorProvider } from "./abstractEditor"; export class PersonEditorProvider extends AbstractEditorProvider { private static readonly viewType = "angular-extension.personEditor"; constructor(context: vscode.ExtensionContext) { super(context); } public static register( context: vscode.ExtensionContext ): vscode.Disposable { const provider = new PersonEditorProvider(context); const providerRegistration = vscode.window.registerCustomEditorProvider( PersonEditorProvider.viewType, provider ); return providerRegistration; } getRootComponentSelector(): string { return "person-root"; } }
- Extend
-
Create a new file angular-extension/src/petEditor.ts
- Copy the content of angular-extension/src/personEditor.ts
- Rename the class to
PetEditorProvider
- Update the value of
viewType
to"angular-extension.petEditor"
- Update the
register()
implementation to register thePetEditorProvider
- Change the return value of
getRootComponentSelector()
topet-root
import * as vscode from "vscode"; import { AbstractEditorProvider } from "./abstractEditor"; export class PetEditorProvider extends AbstractEditorProvider { private static readonly viewType = "angular-extension.petEditor"; constructor(context: vscode.ExtensionContext) { super(context); } public static register( context: vscode.ExtensionContext ): vscode.Disposable { const provider = new PetEditorProvider(context); const providerRegistration = vscode.window.registerCustomEditorProvider( PetEditorProvider.viewType, provider ); return providerRegistration; } getRootComponentSelector(): string { return "pet-root"; } }
-
Update angular-extension/src/extension.ts
- Register the
PetEditorProvider
// The module 'vscode' contains the Visual Studio Code extensibility API // Import the module and reference it with the alias vscode in your code below import * as vscode from "vscode"; import { PersonEditorProvider } from "./personEditor"; import { PetEditorProvider } from "./petEditor"; // This method is called when your extension is activated export function activate(context: vscode.ExtensionContext) { // Register our custom editor provider context.subscriptions.push(PersonEditorProvider.register(context)); context.subscriptions.push(PetEditorProvider.register(context)); } // This method is called when your extension is deactivated export function deactivate() {}
- Register the
Now that the extension code is prepared for multiple custom editors, the webview needs to be implemented in the Angular webview-ui structure. There is also some refactoring involved
-
Update angular-extension/webview-ui/src/app/vscode-textfield-input.directive.ts
I want to use the VSCode Elements - Single Select component in the new pet editor. The VSCode Elements implementation is based on the Lit library. So the usage requires the implementation of a ControlValueAccessor as a Directive, which is described in Getting Started with Visual Studio Code Extension Development. As thevscode-single-select
component also tracks its state in thevalue
attribute, we can simply addvscode-single-select
to theselector
of the existingDirective
.@Directive({ selector: 'vscode-textfield, vscode-single-select', providers: [VSCODE_TEXTFIELD_INPUT_VALUE_ACCESSOR], }) export class VscodeTextfieldInputDirective implements ControlValueAccessor {
We could of course also think about renaming it to something more general, but let’s keep the modifications minimal at this point.
- Create a new folder angular-extension/webview-ui/src/app/person
- Move the following files to angular-extension/webview-ui/src/app/person
- angular-extension/webview-ui/src/app/app.component.html
- angular-extension/webview-ui/src/app/app.component.ts
- angular-extension/webview-ui/src/app/app.component.spec.ts
- Rename those files by replacing app with person
- Edit the file angular-extension/webview-ui/src/app/person/person.component.ts
- Change the selector to
person-root
- Rename the
AppComponent
class toPersonComponent
- Correct the references in
templateUrl
andstyleUrl
@Component({ selector: 'person-root', imports: [ReactiveFormsModule, VscodeTextfieldInputDirective], templateUrl: './person.component.html', styleUrl: '../app.component.css', schemas: [CUSTOM_ELEMENTS_SCHEMA], }) export class PersonComponent {
- Change the selector to
Now create a PetComponent
as the second custom editor in the Angular extension:
- Create a new folder angular-extension/webview-ui/src/app/pet
-
Create a new file angular-extension/webview-ui/src/app/pet/pet.component.html
<main class="main"> <h1>Angular Pet Editor</h1> <div class="content"> <div class="pet"> <div class="row"> <vscode-label for="name">Name:</vscode-label> <div class="value"> <vscode-textfield type="text" [formControl]="name" (input)="updateDocument()" /> </div> </div> <div class="row"> <vscode-label for="species">Species:</vscode-label> <div class="value"> <vscode-single-select [formControl]="species" (input)="updateDocument()" > <vscode-option>-</vscode-option> <vscode-option description="bird">Bird</vscode-option> <vscode-option description="cat">Cat</vscode-option> <vscode-option description="dog">Dog</vscode-option> </vscode-single-select> </div> </div> </div> </div> </main>
-
Create a new file angular-extension/webview-ui/src/app/pet/pet.component.ts
The content of this file is almost the same as angular-extension/webview-ui/src/app/person/person.component.ts, but of course with changes related to the pet data structure and the selector is changed topet-root
.import { Component, CUSTOM_ELEMENTS_SCHEMA, HostListener, } from "@angular/core"; import { FormControl, ReactiveFormsModule } from "@angular/forms"; import { vscode } from "../utilities/vscode"; import { VscodeTextfieldInputDirective } from "../vscode-textfield-input.directive"; import "../../../node_modules/@vscode-elements/elements/dist/vscode-label"; import "../../../node_modules/@vscode-elements/elements/dist/vscode-textfield"; import "../../../node_modules/@vscode-elements/elements/dist/vscode-single-select"; import "../../../node_modules/@vscode-elements/elements/dist/vscode-option"; @Component({ selector: "pet-root", imports: [ReactiveFormsModule, VscodeTextfieldInputDirective], templateUrl: "./pet.component.html", styleUrl: "../app.component.css", schemas: [CUSTOM_ELEMENTS_SCHEMA], }) export class PetComponent { name = new FormControl(""); species = new FormControl(""); petObject: any; constructor() { // Webviews are normally torn down when not visible and re-created when they become visible again. // State lets us save information across these re-loads const state = vscode.getState() as any; if (state) { this.updateContent(state.text); } } // Handle messages sent from the extension to the webview @HostListener("window:message", ["$event"]) handleMessage(event: MessageEvent) { const message = event.data; // The json data that the extension sent switch (message.type) { case "update": const text = message.text; // Update our webview's content this.updateContent(text); // Then persist state information. // This state is returned in the call to `vscode.getState` below when a webview is reloaded. vscode.setState({ text }); return; } } /** * Update the data shown in the document in the webview. */ updateContent(text: string) { if (text !== "") { this.petObject = JSON.parse(text); this.name.setValue(this.petObject.name); this.species.setValue(this.petObject.species); } } /** * Update the document in the extension. */ updateDocument() { let name = this.name.value; let species = this.species.value; // wait 500 ms before updating the document // only update if in the meantime no other input was given setTimeout(() => { if (this.name.value === name && this.species.value === species) { this.petObject = { name: this.name.value, species: this.species.value, }; vscode.postMessage({ type: "updateDocument", text: JSON.stringify(this.petObject, null, 2), }); } }, 500); } }
-
Update the file angular-extension/webview-ui/src/main.ts
- Decide which component to use for bootstrapping based on the
data-root
attribute.
This is actually the crucial step if you want to provide multiple webviews in one extension when you use Angular. The bootstrapping of the standalone component needs to be dynamic, and this is implemented by inspecting an attribute added to the webview html in the extension code.
import { bootstrapApplication } from "@angular/platform-browser"; import { appConfig } from "./app/app.config"; import { PersonComponent } from "./app/person/person.component"; import { PetComponent } from "./app/pet/pet.component"; const currentPath = document.documentElement.getAttribute("data-root"); if (currentPath === "person-root") { bootstrapApplication(PersonComponent, appConfig).catch((err) => console.error(err) ); } else if (currentPath === "pet-root") { bootstrapApplication(PetComponent, appConfig).catch((err) => console.error(err) ); }
- Decide which component to use for bootstrapping based on the
To verify that it works
- Launch the Visual Studio Code Extension(s)
- Open Run and Debug
- Ensure Run Extension is selected in the dropdown
- Click Start Debugging or press F5
- Right click on the file created in the previous example in the Explorer
- Select Open With… - Angular Pet Editor
This should open the webview with the content of the Angular PetComponent
.
React Webview Implementation
To provide multiple webviews in an extension that uses React as webframework, we need to perform similar steps like before with Angular as webframework.
-
Open the react-extension/package.json
- Extend the
contributes
section to add a Pet editor for the .pet file extension:
"contributes": { "customEditors": [ { "viewType": "react-extension.personEditor", "displayName": "React Person Editor", "selector": [ { "filenamePattern": "*.person" } ], "priority": "option" }, { "viewType": "react-extension.petEditor", "displayName": "React Pet Editor", "selector": [ { "filenamePattern": "*.pet" } ], "priority": "option" } ] },
- Extend the
-
Create a new file react-extension/src/abstractEditor.ts
To avoid that we need to copy a lot of code for the new editor, we create an abstract base classAbstractEditorProvider
that will be used by both editor implementations.- Copy the content of react-extension/src/personEditor.ts to react-extension/src/abstractEditor.ts
- Rename
PersonEditorProvider
toAbstractEditorProvider
and make itabstract
- Remove the
viewType
constant and theregister
method - Add an abstract method
getRootComponentSelector()
- Update
getWebviewHtml()
to usegetRootComponentSelector()
as an attributedata-root
in thehtml
tag
import * as vscode from "vscode"; export abstract class AbstractEditorProvider implements vscode.CustomTextEditorProvider { constructor(private readonly context: vscode.ExtensionContext) {} public async resolveCustomTextEditor( document: vscode.TextDocument, webviewPanel: vscode.WebviewPanel, _token: vscode.CancellationToken ): Promise<void> { // Setup initial content for the webview webviewPanel.webview.options = { // Enable scripts in the webview enableScripts: true, // Restrict the webview to only load resources from the `dist` and `webview-ui/build` directories localResourceRoots: [ vscode.Uri.joinPath(this.context.extensionUri, "dist"), vscode.Uri.joinPath(this.context.extensionUri, "webview-ui/build"), ], }; webviewPanel.webview.html = this.getWebviewHtml(webviewPanel.webview); // Hook up event handlers so that we can synchronize the webview with the text document. // // The text document acts as our model, so we have to sync change in the document to our // editor and sync changes in the editor back to the document. // // Remember that a single text document can also be shared between multiple custom // editors (this happens for example when you split a custom editor) const changeDocumentSubscription = vscode.workspace.onDidChangeTextDocument((e) => { if (e.document.uri.toString() === document.uri.toString()) { this.updateWebview(webviewPanel, document); } }); // Make sure we get rid of the listener when our editor is closed. webviewPanel.onDidDispose(() => { changeDocumentSubscription.dispose(); }); // Receive message from the webview. webviewPanel.webview.onDidReceiveMessage((e) => { switch (e.type) { case "updateDocument": this.updateDocument(document, e.text); return; } }); this.updateWebview(webviewPanel, document); } /** * Updates the webview with the content of the current document. * @param webviewPanel The webview panel to post the message to. * @param document The current document. */ private updateWebview( webviewPanel: vscode.WebviewPanel, document: vscode.TextDocument ) { // Post message with text of the document to the webview webviewPanel.webview.postMessage({ type: "update", text: document.getText(), }); } /** * Applies a set of text edits to a document. * @param document The current document. * @param text The text to set to the document. * @returns A thenable that resolves when the edit could be applied. */ private updateDocument(document: vscode.TextDocument, text: string) { const edit = new vscode.WorkspaceEdit(); edit.replace( document.uri, new vscode.Range(0, 0, document.lineCount, 0), text ); return vscode.workspace.applyEdit(edit); } /** * Get the static html used for the editor webviews. */ private getWebviewHtml(webview: vscode.Webview): string { // The CSS file from the React build output const stylesUri = webview.asWebviewUri( vscode.Uri.joinPath( this.context.extensionUri, "webview-ui", "build", "assets", "index.css" ) ); // The JS file from the React build output const scriptUri = webview.asWebviewUri( vscode.Uri.joinPath( this.context.extensionUri, "webview-ui", "build", "assets", "index.js" ) ); const rootSelector = this.getRootComponentSelector(); const nonce = this.getNonce(); // Tip: Install the es6-string-html VS Code extension to enable code highlighting below return /*html*/ ` <!DOCTYPE html> <html lang="en" data-root="${rootSelector}"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${webview.cspSource}; font-src ${webview.cspSource}; style-src ${webview.cspSource}; script-src 'nonce-${nonce}';"> <link rel="stylesheet" type="text/css" href="${stylesUri}"> </head> <body> <div id="root"></div> <script type="module" nonce="${nonce}" src="${scriptUri}"></script> </body> </html>`; } /** * Return the selector of the root component to use. */ abstract getRootComponentSelector(): string; /** * A helper function that returns a unique alphanumeric identifier called a nonce. * * @remarks This function is primarily used to help enforce content security * policies for resources/scripts being executed in a webview context. * * @returns A nonce */ getNonce() { let text = ""; const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; for (let i = 0; i < 32; i++) { text += possible.charAt(Math.floor(Math.random() * possible.length)); } return text; } }
-
Update the file react-extension/src/personEditor.ts
- Extend
AbstractEditorProvider
- Call
super(context)
in theconstructor
- Add the
getRootComponentSelector()
method which returnsperson-root
- Delete the other methods that are now defined in
AbstractEditorProvider
import * as vscode from "vscode"; import { AbstractEditorProvider } from "./abstractEditor"; export class PersonEditorProvider extends AbstractEditorProvider { private static readonly viewType = "react-extension.personEditor"; constructor(context: vscode.ExtensionContext) { super(context); } public static register( context: vscode.ExtensionContext ): vscode.Disposable { const provider = new PersonEditorProvider(context); const providerRegistration = vscode.window.registerCustomEditorProvider( PersonEditorProvider.viewType, provider ); return providerRegistration; } getRootComponentSelector(): string { return "person-root"; } }
- Extend
-
Create a new file react-extension/src/petEditor.ts
- Copy the content of react-extension/src/personEditor.ts
- Rename the class to
PetEditorProvider
- Update the value of
viewType
to"react-extension.petEditor"
- Update the
register()
implementation to register thePetEditorProvider
- Change the return value of
getRootComponentSelector()
topet-root
import * as vscode from "vscode"; import { AbstractEditorProvider } from "./abstractEditor"; export class PetEditorProvider extends AbstractEditorProvider { private static readonly viewType = "react-extension.petEditor"; constructor(context: vscode.ExtensionContext) { super(context); } public static register( context: vscode.ExtensionContext ): vscode.Disposable { const provider = new PetEditorProvider(context); const providerRegistration = vscode.window.registerCustomEditorProvider( PetEditorProvider.viewType, provider ); return providerRegistration; } getRootComponentSelector(): string { return "pet-root"; } }
-
Update react-extension/src/extension.ts
- Register the
PetEditorProvider
// The module 'vscode' contains the Visual Studio Code extensibility API // Import the module and reference it with the alias vscode in your code below import * as vscode from "vscode"; import { PersonEditorProvider } from "./personEditor"; import { PetEditorProvider } from "./petEditor"; // This method is called when your extension is activated export function activate(context: vscode.ExtensionContext) { // Register our custom editor provider context.subscriptions.push(PersonEditorProvider.register(context)); context.subscriptions.push(PetEditorProvider.register(context)); } // This method is called when your extension is deactivated export function deactivate() {}
- Register the
Now that the extension code is prepared for multiple custom editors, the webview needs to be implemented in the React webview-ui structure.
-
Update react-extension/src/webview-ui/src/global.d.ts and add the
VscodeSingleSelect
and theVscodeOption
web component.
To use custom tag names, you must configure the TypeScript parser to recognize the custom elements. This can be done via a TypeScript definition which is described in Getting Started with Visual Studio Code Extension Development.import { VscodeLabel, VscodeTextfield, VscodeSingleSelect, VscodeOption, } from "@vscode-elements/elements"; type ElementProps<I> = Partial<Omit<I, keyof HTMLElement>>; type CustomEventHandler<E> = (e: E) => void; type WebComponentProps<I extends HTMLElement> = React.DetailedHTMLProps< React.HTMLAttributes<I>, I > & ElementProps<I>; declare module "react" { namespace JSX { interface IntrinsicElements { "vscode-label": WebComponentProps<VscodeLabel>; "vscode-textfield": WebComponentProps<VscodeTextfield>; "vscode-single-select": WebComponentProps<VscodeSingleSelect>; "vscode-option": WebComponentProps<VscodeOption>; } } }
- Rename react-extension/src/webview-ui/src/App.tsx to react-extension/src/webview-ui/src/Person.tsx
- In react-extension/src/webview-ui/src/Person.tsx rename the
App
function toPerson
- Create a new file react-extension/src/webview-ui/src/Pet.tsx
-
Copy the content of react-extension/src/webview-ui/src/Person.tsx to the new file and adapt the code to handle a pet instead of a person
import { useState } from "react"; import { vscode } from "./utilities/vscode"; import "./App.css"; import "@vscode-elements/elements/dist/vscode-label"; import "@vscode-elements/elements/dist/vscode-textfield"; import "@vscode-elements/elements/dist/vscode-single-select"; import "@vscode-elements/elements/dist/vscode-option"; function Pet() { const [petObject, setPetObject] = useState(loadState); /** * Load the initial state via vscode API or return an empty object as initial state. */ function loadState() { // eslint-disable-next-line @typescript-eslint/no-explicit-any const state = vscode.getState() as any; if (state) { return JSON.parse(state.text); } return { name: "", species: "", }; } // Handle messages sent from the extension to the webview window.addEventListener("message", (event) => { const message = event.data; // The json data that the extension sent switch (message.type) { case "update": { const text = message.text; // Update our webview's content updateContent(text); // Then persist state information. // This state is returned in the call to `vscode.getState` below when a webview is reloaded. vscode.setState({ text }); return; } } }); /** * Update the data shown in the document in the webview. */ function updateContent(text: string) { if (text !== "") { const parsed = JSON.parse(text); setPetObject({ name: parsed.name, species: parsed.species, }); } } /** * Update the document in the extension. */ function updateDocument() { vscode.postMessage({ type: "updateDocument", text: JSON.stringify(petObject, null, 2), }); } return ( <> <main className="main"> <h1>React Pet Editor</h1> <div className="content"> <div className="pet"> <div className="row"> <vscode-label htmlFor="name" className="vscode-label"> Name: </vscode-label> <div className="value"> <vscode-textfield type="text" id="name" className="vscode-textfield" value={petObject.name} onInput={(event) => { const value = event.currentTarget.value; const target = event.currentTarget; // wait 500 ms before updating the document // only update if in the meantime no other input was given setTimeout(() => { if (value === target.value) { petObject.name = value; updateDocument(); } }, 500); }} /> </div> </div> <div className="row"> <vscode-label htmlFor="species" className="vscode-label"> Species: </vscode-label> <div className="value"> <vscode-single-select id="species" value={petObject.species} onInput={(event) => { const value = event.currentTarget.value; const target = event.currentTarget; // wait 500 ms before updating the document // only update if in the meantime no other input was given setTimeout(() => { if (value === target.value) { petObject.species = value; updateDocument(); } }, 500); }} > <vscode-option>-</vscode-option> <vscode-option description="bird">Bird</vscode-option> <vscode-option description="cat">Cat</vscode-option> <vscode-option description="dog">Dog</vscode-option> </vscode-single-select> </div> </div> </div> </div> </main> </> ); } export default Pet;
-
Update react-extension/src/webview-ui/src/main.tsx
- Decide which component to use as root based on the
data-root
attribute
This is actually the crucial step if you want to provide multiple webviews in one extension when you use React. The definition of the root component needs to be dynamic, and this is implemented by inspecting an attribute added to the webview html in the extension code.
import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import "./index.css"; import Person from "./Person.tsx"; import Pet from "./Pet.tsx"; createRoot(document.getElementById("root")!).render( <StrictMode> <RootComponent /> </StrictMode> ); // eslint-disable-next-line react-refresh/only-export-components function RootComponent() { const currentPath = document.documentElement.getAttribute("data-root"); if (currentPath === "person-root") { return <Person />; } else if (currentPath === "pet-root") { return <Pet />; } }
- Decide which component to use as root based on the
To verify that it works
- Launch the Visual Studio Code Extension(s)
- Open Run and Debug
- Ensure Run Extension is selected in the dropdown
- Click Start Debugging or press F5
- Right click on the file created in the previous example in the Explorer
- Select Open With… - React Pet Editor
This should open the webview with the content of the React Pet
component.
Bonus: Automatic Termination of Watch Tasks
In the setup of this project multiple watch tasks are started when you launch the Visual Studio Code Extensions for debugging via Run and Debug or by pressing F5 or F11.
I configured the watch tasks as defaultBuildTask
to get code changes directly reflected in the running instance.
This is similar to the Hot Code Replace debugging technique in Java.
After a code change in a webview implementation, the changes can be directly seen by either closing and reopening the webview, or by reloading the webview by pressing F1 - Developer: Reload Webviews. To reload the Extension Development Host after changes in the extension code, you can either click on the debug restart action or press Ctrl + R
/ Cmd + R
in the Extension Development Host window. This makes the development flow a bit more comfortable compared to always having to close and restart the debugging launch configuration.
There is a nasty side effect with this setup. If you close the Extension Development Host, the watch tasks are not automatically stopped. So if you want to start a new debugging session afterwards, nothing happens, because the watch scripts are still running and therefore the patterns that are used to match the started state are not matched in a new start. To make the start of a debug instance work again, you first need to stop the running watch tasks, so they are started freshly. As this is quite annoying when it happens often that you kill the Extension Development Host instead of reloading it, I was searching for a solution. A colleague of mine found a nice solution for this, and I modified it to be even more convenient. Of course I found the necessary information in the web, but you know, the blog posts are my external memory.
To automatically terminate the watch tasks
- Open the file .vscode/tasks.json
- Add the following
Terminate Tasks
to thetasks
and theterminate
input to theinputs
{ "version": "2.0.0", "tasks": [ ... { "label": "Terminate Tasks", "command": "echo ${input:terminate}", "type": "shell", "problemMatcher": [], "presentation": { "reveal": "never", "close": true } }, ], "inputs": [ { "id": "terminate", "type": "command", "command": "workbench.action.tasks.terminate", "args": "terminateAll" } ] }
- Add the following
The above Terminate Tasks
configuration triggers the workbench.action.tasks.terminate
command with the argument terminateAll
.
So actually this kills all running tasks.
Via the close
property of the presentation
task property, we configure that the terminal the tasks runs in is closed the task exits.
This way our Terminal list stays clean.
- Open the file .vscode/launch.json
- Add
"postDebugTask": "Terminate Tasks"
to theRun Extension
configuration{ "version": "0.2.0", "configurations": [ { "name": "Run Extension", "type": "extensionHost", "request": "launch", "args": [ "--extensionDevelopmentPath=${workspaceFolder}/vscode-extension", "--extensionDevelopmentPath=${workspaceFolder}/angular-extension", "--extensionDevelopmentPath=${workspaceFolder}/react-extension" ], "outFiles": [ "${workspaceFolder}/vscode-extension/out/**/*.js", "${workspaceFolder}/angular-extension/dist/**/*.js", "${workspaceFolder}/react-extension/dist/**/*.js" ], "preLaunchTask": "${defaultBuildTask}", "postDebugTask": "Terminate Tasks" }, ... ] }
- Add
If you now start the debugging instance via the Run Extension
launch configuration, you will again see that the three watch scripts are started before the Extension Development Host comes up. If you now stop the Extension Development Host, the watch scripts will be stopped also.
Note:
This will also affect the restart behavior of the Extension Development Host. Without the postDebugTask
the Extension Development Host will restart while the watch tasks keep running. With the postDebugTask
the watch tasks will be killed and also restarted. If you are working on the extension sources and need to restart the Extension Development Host to make the changes visible, it might be annoying that the watch scripts are killed and restarted. But if you are working on the webview sources and don’t need to restart the Extension Development Host, this change might not really be noticable.
Conclusion
With a Vanilla Javascript and HTML webview, it is straight forward to provide multiple webviews in one extension. The webview HTML is provided by each custom editor provider implementation. And the special handling in the Javascript needs to be implemented accordingly.
Using a webframework like Angular or React the crucial fact is to determine the root component dynamically. If you know how this can be done, it is quite easy, but finding out how to achieve this was indeed a journey.
If you are annoyed by watch scripts that keep running after closing the Extension Development Host, the postDebugTask
of a launch configuration could be helpful. But there are also use cases where the behavior that is introduced by the postDebugTask
could be annoying, e.g. on restarting the Extension Development Host. So you need to use that configuration based on your use cases and personal development/testing preferences.
The sources for this and the following tutorials are located in my Github repository.