Visual Studio (VS) Code is one of the most preferred code editors that developers use in their everyday tasks. It's built with extendibility in mind. To a certain extent, most of the core functionalities of VS Code are built as extensions. You can check the VS Code extensions repository (https://github.com/microsoft/vscode/tree/main/extensions) to get an idea of what I'm talking about.

VS Code, under the hood, is an electron (https://www.electronjs.org/) cross-environment application that can run on UNIX, Mac OSX, and Windows operating systems. Because it's an electron application, you can extend it by writing JavaScript plug-ins. In fact, any language that can transpile to JavaScript can be used to build an extension. For instance, the VS Code docs website prompts the use of TypeScript (https://www.typescriptlang.org/) to write VS Code extensions. All the code examples (https://github.com/microsoft/vscode-extension-samples), provided by the VS Code team, are built using TypeScript.

VS Code supports a very extensive API that you can check and read on VS Code API (https://code.visualstudio.com/api).

VS Code allows you to extend almost any feature that it supports. You can build custom commands, create a new color theme, embed custom HTML inside a WebView, contribute to the activity bar by adding new views, make use of a Tree View to display hierarchical data on the sidebar, and many other extendibility options. The Extensions Capabilities Overview page (https://code.visualstudio.com/api/extension-capabilities/overview) details all the VS Code extension capabilities. In case you want to skip the overview and go directly to the details on how to build real-world extensions with capabilities, check the Extensions Guides page (https://code.visualstudio.com/api/extension-guides/overview).

Building extensions in VS Code is a huge topic that can be detailed into many books and countless articles. In this article, I will focus on:

  • Creating VS Code Commands
  • Using the Webview API to embed a Vue.js app inside Webview panels and views
  • Adding a View Container to the Activity Bar

VS Code UI Architecture

Before I delve into building extensions, it's important to understand the parts and sections that make up the VS Code UI.

I'll borrow two diagrams from the VS Code website to help illustrate the concepts. Figure 1 Illustrates the major sections of the VS Code UI.

Figure 1: VS Code Main sections
Figure 1: VS Code Main sections

VS Code has the following main sections:

  • Activity Bar: Every icon on the Activity Bar represents a View Container. In turn, this container hosts one or more views inside. In addition, you can extend the existing ones too. For example, you can add a new View into the Explorer View.
  • Sidebar: A Sidebar is a container to host Views. For example, you can add a Tree View or Webview View to the Sidebar.
  • Editor: The Editor hosts the different types of editors that VS Code uses. For instance, VS Code uses a text editor to allow you to read/write a file. Another kind of editor allows you to edit Workspace and User settings. You can also contribute your own editor using a Webview for instance.
  • Panel: The Panel allows you to add View Containers with Views.
  • Status Bar: The Status Bar hosts Status Bar Items that can use text and icons to display. You can also treat them as commands to trigger an action when clicking them.

Figure 2 illustrates what goes inside the major sections of the VS Code UI.

Figure 2: VS Code section details
Figure 2: VS Code section details
  • The Activity Bar hosts View Containers, which, in turn, host Views.
  • A View has a View Toolbar.
  • The Sidebar has a Sidebar Toolbar.
  • The Editor has an Editor Toolbar.
  • The Panel hosts View Containers, which, in turn, host Views.
  • A Panel has a Panel Toolbar.

VS Code allows us to extend any of the major and minor sections using its API.

VS Code API is rich enough to allow developers to extend almost every feature it offers.

Your First VS Code Extension

To start building your own custom VS Code extensions, make sure that you have Node.js (https://nodejs.org/en/) and Git (https://git-scm.com/) both installed on your computer. It goes without saying that you need to have VS Code (https://code.visualstudio.com/download) installed on your computer too.

I'll be using the Yeoman (https://yeoman.io/) CLI to generate a new VS Code extension project. Microsoft offers and supports the Yo Code (https://www.npmjs.com/package/generator-code) Yeoman generator to scaffold a complete VS Code extension in either TypeScript or JavaScript.

Let's start!

Step 1

Install the Yeoman CLI and Yo Code generator by running the following command:

npm install -g yo generator-code

Step 2

Run the following command to scaffold a TypeScript or JavaScript project ready for development.

yo code

During the process of creating the new VS Code extension project, the code-generator asks some questions. I'll go through them to create the app.

Figure 3 shows the starting point of the generator.

Figure 3: Start extension project scaffolding
Figure 3: Start extension project scaffolding

You can either pick TypeScript or JavaScript. Most of the examples you find online are written in TypeScript. It would be smart to go with TypeScript to make your life easier when writing and authoring your extension.

Next, you need to provide the name of your extension, as shown in Figure 4.

Figure 4: Naming the VS Code extension
Figure 4: Naming the VS Code extension

Now, you specify an identifier (ID) of your extension. You can leave the default or provide your own. I tend to use no spaces or dashes (-) to separate the identifier name.

Then, you can provide a description of your extension.

The next three questions are shown in Figure 5.

  • Initialize a Git repository? Yes
  • Use Webpack to bundle the extension? Yes
  • Which package manager to use? npm
Figure 5: Finalize the code-generator scaffolding
Figure 5: Finalize the code-generator scaffolding

The generator takes all your answers and scaffolds your app. Once done, move inside the new extension folder and open VS Code by running this command:

cd vscodeexample && code .

Step 3

Let's quickly explore the extension project.

Figure 6 lists all the files that the Yo Code generated for you.

Figure 6: Project files
Figure 6: Project files
  • The /.vscode/ directory contains configuration files to help us test our extension easily.
  • The /dist/ directory contains the compiled version of the extension.
  • The /src/ directory contains the source code you write to build the extension.
  • The package.json file is the default NPM configuration file. You use this file to define your custom command, views, menus, and much more.
  • The vsc-extension-quickstart.md file contains an introduction to the extension project and documentation on how to get started building a VS Code extension.

Microsoft offers the Yo Code Yeoman generator to help you scaffold a VS Code extension project quickly and easily.

Step 4

Open the package.json file and let's explore the important sections you need for building this extension.

"contributes": {
    "commands": [
        {
            "command": "vscodeexample.helloWorld",   
            "title": "Hello World"
        }
    ]
},

You define your custom commands inside the contributes section. You provide command and title, as a minimum, when you define a new command. The command should uniquely identify your command. By default, the command used is a concatenation of the extension identifier that you've specified at the time of scaffolding the extension together with an arbitrary string that represents the command you're providing. The new command automatically shows up now in the Command Palette.

VS Code defines a lot of built-in commands that you can even consume programmatically. For instance, you can execute the workbench.action.newWindow command to open a new VS Code instance.

Here's a complete list of built-in commands (https://code.visualstudio.com/api/references/commands) in VS Code.

The command does nothing for now. You still need to bind this command to a command handler that I'll define shortly. VS Code provides the registerCommand() function to do the association for you.

You should define an Activation Event that will activate the extension when the user triggers the command. It's the Activation Event that lets VS Code locate and bind a command to a command handler. Remember, extensions aren't always activated by default. For example, an extension might be activated when you open a file with a specific file extension. That's why it's needed to make sure the extension is activated before running any command.

The package.json file defines an activationEvents section:

"activationEvents": [ "onCommand:vscodeexample.helloWorld" ],

When the user invokes the command from the Command Palette or through a keybinding, the extension will be activated and registerCommand() function will bind the (vscodeexample.helloWorld) to the proper command handler.

Step 5

It's time to explore the extension source code and register the command together with a command handler. The extension source code lies inside the /src/extension.ts file. I've cleaned up this file as follows:

import * as vscode from 'vscode';

export function activate(
    context: vscode.ExtensionContext) {
        context.subscriptions.push(...);
    }

export function deactivate() {}

VS Code calls the activate() function when it wants to activate the extension. Similarly, when it calls the deactivate() function, it wants to deactivate it. Remember, the extension is activated only when one of your declared Activation Events happens.

If you instantiate objects inside the command handler and want VS Code to release them for you later, push the new command registration into the context.subscriptions array. VS Code maintains this array and will do garbage collection on your behalf.

Let's register the Hello World command as follows:

context.subscriptions.push(
    vscode.commands.registerCommand(
       'vscodeexample.helloWorld',
       () => {
           vscode.window.showInformationMessage('...');
        }
    )
);

The vscode object is the key to access the entire VS Code API. You register a command handler similarly to how you register DOM events in JavaScript. The code binds the same command identifier, the one that you previously declared inside the package.json file under the commands and activationEvents sections, to a command handler.

VS Code shows an information message when the user triggers the command.

Let's test the extension by clicking F5. VS Code opens a new instance loaded with the new extension. To trigger the command, open the Command Palette and start typing “hello”.

Figure 7 shows how VS Code filters the available commands to the one you are after.

Figure 7: Command Palette filtered
Figure 7: Command Palette filtered

Now click the command, and Figure 8 shows how the information message appears on the bottom right side of the editor.

Figure 8: Showing information message
Figure 8: Showing information message

Congratulations! You've just completed your first VS Code extension!

Build a VS Code Extension with Vue.js Using Vue CLI

Let's use your newfound knowledge and build something more fun!

In this section, you'll use the Vue CLI to create a new Vue.js app and host it inside a Webview as a separate editor.

The Webview API allows you to create fully customizable views within the VS Code. I like to think of Webview as an iframe inside VS Code. It can render any HTML content inside this frame. It also supports two-way communication between the extension and the loaded HTML page. The view can post a message to the extension and vice-versa.

Webview API supports two types of views that I'm going to explore in this article:

  • WebviewPanel is a wrapper around a Webview. It's used to display a Webview inside an editor in VS Code.
  • WebviewView is a wrapper around a Webview. It's used to display a Webview inside the Sidebar.

In both types, the Webview hosts HTML content!

The Webview API documentation is rich and contains all the details you need to use it. Check it out here at Webview API (https://code.visualstudio.com/api/extension-guides/webview).

Let's start building our Vue.js sample application and hosting it inside an editor in VS Code.

Webview allows you to enrich your VS Code extension by embedding HTML content together with JavaScript and CSS resource files.

Step 1

Generate a new VS Code extension project using Yeoman, as you did above.

Step 2

Add a new command to open the Vue.js app. Locate the package.json file and add the following:

"contributes": { 
    "commands": [ 
        {
            "command": "vscodevuecli:openVueApp", 
            "title": "Open Vue App"
        }
    ]
},

The command has an identifier of vscodevuecli:openVueApp.

Then you declare an Activation Event as follows:

"activationEvents": ["onCommand:vscodevuecli:openVueApp"],

Step 3

Switch to the extension.js file and inside the activate() function register the command handler.

context.subscriptions.push(   
    vscode.commands.registerCommand(
        'vscodevuecli:openVueApp', () => 
            {
                WebAppPanel.createOrShow(context.extensionUri);
            }
    )
);

Inside the command handler, you're instantiating a new instance of WebAppPanel class. It's just a wrapper around a WebviewPanel.

Step 4

In this step, you'll generate a new Vue.js app using the Vue CLI. Follow this guide (https://cli.vuejs.org/guide/creating-a-project.html#vue-create) to scaffold a new Vue.js app inside the /web/ directory at the root of the extension project.

Make sure to place any image you use inside the /web/img/ directory. Later on, you'll copy this directory to the dist directory when you compile the app.

Usually, the HTML page, hosting the Vue.js app, requests images to render from the local file system on the server. However, when the Webview loads the app, it can't just request and access the local file system. For security reasons, the Webview should be limited to a few directories inside the project itself.

Also, VS Code uses special URIs to load any resource inside the Webview including JavaScript, CSS, and image files. Therefore, you need a way to base all the images, so you use the URI that VS Code uses to access the local resources. The extension, as you'll see in Step 5, injects the VS Code base URI, into the body of the HTML of the Webview, so that the Vue.js app can use it to base its images.

Therefore, to make use of the injected base URI, you'll add a Vue.js mixin that reads the value of the base URI from the HTML DOM and makes it available to the Vue.js app.

Note that if you want to run the Vue.js app outside the Webview, you need to place the following inside the /web/public/index.html file:

<body>   
    <input hidden data-uri="">
    ...
</body>

Inside the /web/src/mixins/ExtractBaseUri.js file, define a new Vue.js mixin. It makes available the baseUri data option to any Vue.js component:

data() {
    return {
        baseUri: '',   
    };
},

It then uses the Vue.js mounted() lifecycle hook to extract the value:

mounted() {
    const dataUri = document.querySelector('input[data-uri]'); 
    if (!dataUri) return;

    this.baseUri = dataUri.getAttribute('data-uri');
},

If it finds an input field with a data attribute named data-uri, it reads the value and assigns it to the baseUri property.

The next step is to provide the mixin inside the /web/src/main.js file:

Vue.mixin(ExtractBaseUri);

Switch to the App.vue component and replace the image element with the following:

<img alt="Vue logo" :src="`${baseUri}/img/logo.png`">

Now that the app is ready to run both locally and inside the Webview, let's customize the compilation process via the Vue.js configuration file.

Create a new /web/vue.config.js file. Listing 1 shows the entire source code for this file.

Listing 1: vue.config.js

const path = require('path');

module.exports = {
    filenameHashing: false,
    outputDir: path.resolve(__dirname, "../dist-web"),  
    chainWebpack: config => {   
        config.plugin('copy') 
            .tap(([pathConfigs]) => {
                const to = pathConfigs[0].to
                // so the original `/public` folder keeps priority
                pathConfigs[0].force = true

                // add other locations.
                pathConfigs.unshift({ 
                    from: 'img',  
                    to: `${to}/img`,
                })
                
                return [pathConfigs]    
            })
    },
}

Basically, you're doing the following:

  • Removing the hashes from the compiled file names. The compiled JavaScript file will look like app.js only without any hashes in the file name.
  • Sets the output directory to be /dist-web/. The Vue CLI uses this property to decide where to place the compiled app files.
  • Copy to the destination directory the /web/img/ directory and all of its content.

Next, let's fix the NPM scripts so that you can compile both the extension files and the Vue.js app at the same time using a single script.

First, start by installing the Concurrently NPM package by running the following command:

npm i --save-dev concurrently

Then, locate the package.json file and replace the watch script with this:

"watch": "concurrently \"npm --prefix web run dev\"
                       \"webpack --watch\"",

The watch script now compiles both the Vue.js app and the extension files every time you change any files in both folders.

Run the following command to compile both apps and generate the /dist-web/ directory:

npm run watch

That's it for now! The Vue.js app is ready for hosting inside a Webview.

Step 5

Add a new TypeScript file inside the /src/ directory and name it WebAppPanel.ts. Listing 2 has the full source code for this file. Let's dissect it and explain the most relevant parts of it.

Listing 2: WebAppPanel.ts

import * as vscode from "vscode";
import { getNonce } from "./getNonce";

export class WebAppPanel {

    public static currentPanel: WebAppPanel | undefined;

    public static readonly viewType = "vscodevuecli:panel";

    private readonly _panel: vscode.WebviewPanel;  
    private readonly _extensionUri: vscode.Uri;  
    private _disposables: vscode.Disposable[] = [];

    public static createOrShow(extensionUri: vscode.Uri) { 
        const column = vscode.window.activeTextEditor
        ? vscode.window.activeTextEditor.viewColumn: undefined;

        // If we already have a panel, show it.      
        if (WebAppPanel.currentPanel) {
            WebAppPanel.currentPanel._panel.reveal(column);
            return;     
        }
        
        // Otherwise, create a new panel. 
        const panel = vscode.window.createWebviewPanel(
            WebAppPanel.viewType,
            'Web App Panel',
            column || vscode.ViewColumn.One,
            getWebviewOptions(extensionUri),
        );

        WebAppPanel.currentPanel = new WebAppPanel(panel, extensionUri);    
    }

    public static kill() { 
        WebAppPanel.currentPanel?.dispose();
        WebAppPanel.currentPanel = undefined; 
    }

    public static revive(panel: vscode.WebviewPanel,
        extensionUri: vscode.Uri) {    
        WebAppPanel.currentPanel = new WebAppPanel(panel, extensionUri);  
    }

    private constructor(panel: vscode.WebviewPanel,
        extensionUri: vscode.Uri) {    
            this._panel = panel;    
            this._extensionUri = extensionUri;

        // Set the webview's initial html content    
            this._update();

            this._panel.onDidDispose(() => this.dispose(), 
                null, this._disposables);
            
        // Update the content based on view changes 
            this._panel.onDidChangeViewState(  
                e => {
                    if (this._panel.visible) {  
                        this._update();
                    }
                },
                null,
                this._disposables
            );

            // Handle messages from the webview  
            this._panel.webview.onDidReceiveMessage(    
                message => {
                    switch (message.command) {
                        case 'alert': vscode.window.showErrorMessage(message.text); 
                        return;
                    }
                },
                null,
                this._disposables 
            );
        }

        public dispose() {    
            WebAppPanel.currentPanel = undefined;  

            // Clean up our resources  
            this._panel.dispose();

            while (this._disposables.length) {
                const x = this._disposables.pop(); 
                    if (x) {
                    x.dispose();
                    }
            }
        }

        private async _update() {
            const webview = this._panel.webview;    
            this._panel.webview.html = this._getHtmlForWebview(webview);  
        }
        
        private _getHtmlForWebview(webview: vscode.Webview) {    
            const styleResetUri = webview.asWebviewUri(      
                vscode.Uri.joinPath(this._extensionUri, "media", "reset.css")   
            );

            const styleVSCodeUri = webview.asWebviewUri(    
                vscode.Uri.joinPath(this._extensionUri, "media", "vscode.css")
            );
            const scriptUri = webview.asWebviewUri( 
                vscode.Uri.joinPath(this._extensionUri, "dist-web", "js/app.js")
            );
            
            const scriptVendorUri = webview.asWebviewUri(
                vscode.Uri.joinPath(this._extensionUri, "dist-web", 
                    "js/chunk-vendors.js")
            );

            const nonce = getNonce();  
            const baseUri = webview.asWebviewUri(vscode.Uri.joinPath(
                this._extensionUri, 'dist-web')
                ).toString().replace('%22', '');

            return `      
                <!DOCTYPE html>
                <html lang="en">
                <head>
                    <meta charset="utf-8" />
                    <meta name="viewport" content="width=device-width, 
                        initial-scale=1" />
                    <link href="${styleResetUri}" rel="stylesheet">
                    <link href="${styleVSCodeUri}" rel="stylesheet">
                    <title>Web App Panel</title>
                </head>
                <body>
                <input hidden data-uri="${baseUri}">
                    <div id="app"></div>  
                    <script type="text/javascript"
                        src="${scriptVendorUri}" nonce="${nonce}"></script>  
                    <script type="text/javascript"
                        src="${scriptUri}" nonce="${nonce}"></script>
                </body>
                </html> 
            `;  
        }
}
function getWebviewOptions(extensionUri: vscode.Uri): 
vscode.WebviewOptions {    
    return {
        // Enable javascript in the webview
        enableScripts: true,

        localResourceRoots: [  
            vscode.Uri.joinPath(extensionUri, 'media'),  
            vscode.Uri.joinPath(extensionUri, 'dist-web'),
        ]
    };
}

You define the WebAppPanel class as a singleton to make sure there's always a single instance of it. This is done by adding the following:

public static currentPanel: WebAppPanel | undefined;

It wraps an instance of the WebviewPanel and tracks it by defining the following:

private readonly _panel: vscode.WebviewPanel;

The createOrShow() function is the core of WebAppPanel class. It checks to see whether the currentPanel is already instantiated, and it shows the WebviewPanel right away.

if (WebAppPanel.currentPanel) {   
    WebAppPanel.currentPanel._panel.reveal(column);
    return;
}

Otherwise, it instantiates a new WebviewPanel using the createWebviewPanel() function as follows:

const panel = vscode.window.createWebviewPanel(   
    WebAppPanel.viewType,
    'Web App Panel',    
    column || vscode.ViewColumn.One,    
    getWebviewOptions(extensionUri),
);

This function accepts the following parameters:

  • viewType: A unique identifier specifying the view type of the WebviewPanel
  • title: The title of the WebviewPanel
  • showOptions: Where to show the Webview in the editor
  • options: Settings for the new Panel

The options are prepared inside the getWebviewOptions() function.

function getWebviewOptions(   
    extensionUri: vscode.Uri
): vscode.WebviewOptions {
    return {    
        enableScripts: true,
        localResourceRoots: [
            vscode.Uri.joinPath(extensionUri, 'media'),
            vscode.Uri.joinPath(extensionUri, 'dist-web'),
        ]
    };
}

It returns an object that has two properties:

  • enableScripts: Controls whether scripts are enabled in the Webview content or not
  • localResourceRoots: Specifies the root paths from which the Webview can load local resources using URIs (Universal Resource Identifier representing either a file on disk or any other resource). This guarantees that the extension cannot access files outside the paths you specify.

WebviewPanel wraps a Webview to render inside a VS code editor.

The createOrShow() function ends by setting the value of the currentPanel to a new instance of the WebAppPanel by calling its private constructor.

The most important section of the constructor is setting the HTML content of the Webview as follows:

this._panel.webview.html = this._getHtmlForWebview(webview);

The _getHtmlForWebview() function prepares and returns the HTML content.

There are two CSS files that you'll embed in almost every Webview you create. The reset.css file resets some CSS properties inside the Webview. Although the vscode.css file contains the default theme colors and CSS properties of the VS Code. This is essential to give your Webview the same look and feel as any other editor in VS Code.

const styleResetUri = webview.asWebviewUri(   
    vscode.Uri.joinPath(this._extensionUri, "media", "reset.css"));

const styleVSCodeUri = webview.asWebviewUri(   
    vscode.Uri.joinPath(this._extensionUri, "media", "vscode.css"));

The _extensionUri property represents the URI of the directory containing the current extension. The Webview asWebviewUri() function converts a URI for the local file system to one that can be used inside Webviews. They cannot directly load resources from the Workspace or local file system using file: URIs. The asWebviewUri() function takes a local file: URI and converts it into a URI that can be used inside a Webview to load the same resource.

The function then prepares the URIs for the other resources including the js/app.js and js/chunk-vendors.js files that were compiled by the Vue CLI back in Step 5.

Remember from Step 4, the Vue CLI copies all images inside the /dist-web/img/ directory. All image paths inside the Vue.js app use a base URI that points to either a VS Code URI when running inside the Webview or a file: URI when running in a standalone mode.

At this stage, you need to generate a VS Code base URI and inject it into the hidden input field that the Vue.js loads and reads via the Vue.js mixin.

The WebAppPanel generates the VS Code base URI of the extension using the following code:

const baseUri = 
    webview.asWebviewUri(
        vscode.Uri.joinPath(
            this._extensionUri, 'dist-web'
        )
    ).toString().replace('%22', '');

It communicates this URI to the Vue.js app by setting the data-uri data attribute value on a hidden input field inside the HTML page that's also loading the Vue.js app.

Finally, the function embeds all the CSS and JavaScript URIs inside the HTML page content and returns it.

That's it!

Let's run the extension by clicking the F5 key, and start typing “Open Vue App” inside the Command Palette of the VS Code instance that just opened, as shown in Figure 9.

Figure 9: Open Vue App command
Figure 9: Open Vue App command

Click the command to load the Vue.js app inside a Webview in a new editor window, as shown in Figure 10.

Figure 10: Vue app loading inside VS Code extension
Figure 10: Vue app loading inside VS Code extension

That's all you need to have a Vue.js app generated by Vue CLI load inside a VS Code extension.

Build a VS Code Extension with Vue.js using Rollup.js

In this section, I'll expand on what you've built so far and introduce a new scenario where the Vue CLI might not be the right tool for the job.

As you know, the Vue CLI compiles the entire Vue.js app into a single app.js file. Let's put aside the chunking feature offered by the CLI for now.

When building a VS Code extension, there are times when you need to load one HTML page inside a WebviewPanel in an editor. At the same time, you might need to load another HTML page inside a WebviewView in the Sidebar. Of course, you can use plain HTML and JavaScript to build your HTML, but because you want to use Vue.js to build your HTML pages, the Vue CLI is not an option in this case.

You need to create a Vue.js app that contains multiple small and independent Vue.js components that are compiled separately into separate JavaScript files and not just merged into a single app.js file.

I came up with a solution that involves creating micro Vue.js apps using a minimum of two files. A JavaScript file and one or more Vue.js components (a root component with many child components). The JavaScript file imports the Vue.js framework and mounts the corresponding Vue.js root component into the DOM inside the HTML page.

For this solution, I've decided to use Rollup.js (https://rollupjs.org/) to compile the files.

Let's explore this solution together by building a new VS Code extension that does two things:

  • Uses a WebviewPanel to host a Vue.js app (or root component) into a new editor
  • Uses a WebviewView to host a Vue.js app (or root component) into the Sidebar

Step 1

Generate a new VS Code extension project using Yeoman, as you did before.

Step 2

Add a new command to open the Vue.js app. Locate the package.json file and add the following:

"contributes": {
    "commands": [ 
        { 
            "command": "vscodevuerollup:openVueApp", 
            "title": "Open Vue App", 
            "category": "Vue Rollup"    
        }
    ]
},

The command has an identifier of vscodevuerollup:openVueApp.

Then you declare an Activation Event:

"activationEvents": ["onCommand:vscodevuerollup:openVueApp"],

In addition, define a new View Container to load inside the Activity Bar. Listing 3 shows the sections that you need to add inside the package.json file.

Listing 3: Add a View Container

    "viewsContainers": {
        "activitybar": [   
            {
                "id": "vscodevuerollup-sidebar-view", 
                "title": "Vue App",     
                "icon": "$(remote-explorer)"
            }
        ]
    },
    "views": {
        "vscodevuerollup-sidebar-view": [
            {
                "type": "webview",      
                "id": "vscodevuerollup:sidebar",
                "name": "vue with rollup",
                "icon": "$(remote-explorer)",
                "contextualTitle": "vue app"  
            }
        ]
    },

The Activity Bar entry has an ID of vscodevuerollup-sidebar-view. This ID matches the ID of the collection of Views that will be hosted inside this View Container and that's defined inside the views section.

"views": {"vscodevuerollup-sidebar-view": [...]}

The (vscodevuerollup-sidebar-view) entry represents a collection of Views. Every View has an ID.

{   
    "type": "webview",  
    "id": "vscodevuerollup:sidebar",   
    "name": "vue with rollup", 
    "icon": "$(remote-explorer)",  
    "contextualTitle": "vue app"
}

Make note of this ID vscodevuerollup:sidebar, scroll up to the activatinEvents section, and add the following entry:

onView:vscodevuerollup:sidebar

When using the onView declaration, VS Code activates the extension when the View, with the specified ID, is expanded on the Sidebar.

Step 3

Switch to the extension.js file and inside the activate() function register the command handlers.

First, register the vscodevuerollup:openVueApp command:

context.subscriptions.push(
    vscode.commands.registerCommand(
        'vscodevuerollup:openVueApp', async (args) => {
            WebAppPanel.createOrShow(context.extensionUri); 
        }
    )
);

Then register the vscodevuerollup:sendMessage command:

const sidebarProvider = new SidebarProvider(context.extensionUri);

context.subscriptions.push(
    vscode.window.registerWebviewViewProvider(
        SidebarProvider.viewType,
        sidebarProvider
    )
);

You're instantiating a new instance of the SidebarProvider class and using the vscode.window.registerWebviewViewProvider() function to register this provider.

Here, you're dealing with the second type of Webviews that I've mentioned earlier, the WebviewView. To load a Webview into the Sidebar, you need to create a class that implements the WebviewViewProvider interface. It's just a wrapper around a WebviewView.

WebviewViewProvider wraps a WebviewView, which, in turn, wraps a Webview. The WebviewView renders inside the Sidebar in VS Code.

Step 4

In this step, you'll create a custom Vue.js app. Start by creating the /web/ directory at the root folder of the extension.

Inside this directory, create three different sub-directories:

  • pages: This directory holds all the Vue.js pages.
  • components: This holds all the Vue.js Single File Components (SFC).
  • img: This holds all the images you use in your Vue.js components.

Let's add the first Vue.js page by creating the /web/pages/App.js file and pasting this code inside it:

import Vue from "vue";
import App from "@/components/App.vue";

new Vue({render: h => h(App)}).$mount("#app");

There's no magic here! It's the same code that the Vue CLI uses inside the main.js file to load and mount the Vue.js app on the HTML DOM. However, in this case, I'm just mounting a single Vue.js component. Think of this component as a root component that might use other Vue.js components in a tree hierarchy.

Note that I've borrowed the same App.vue file from the Vue CLI files you created previously.

Let's add another page by creating the /web/pages/Sidebar.js file and pasting this code inside it:

import Vue from "vue";
import Sidebar from "@/components/Sidebar.vue";

new Vue({render: h => h(Sidebar)}).$mount("#app");

This page loads and mounts the Sidebar.vue component.

Listing 4 shows the complete content of the Sidebar.vue component. It defines the following UI sections:

  • Display the messages received from the extension.
  • Allow the user to send a message to the extension from within the Vue.js app.
  • Execute a command on the extension to load the App.js page in a Webview inside an editor.

Listing 4: Sidebar.vue component

<template>  
    <div> 
        <p>Message received from extension</p>  
        <span>{{ message }}</span>

        <p>Send message to extension</p>
        <input type="text" v-model="text">
        <button @click="sendMessage">Send</button>

        <p>Open Vue App</p>
        <button @click="openApp">Open</button>
    </div>
</template>

<script> export default {
    data() {
        return {     
            message: '',
            text: '',   
        };
    },
    mounted() {
        window.addEventListener('message', this.receiveMessage);
    },
    beforeDestroy() {    
        window.removeEventListener('message', this.receiveMessage); 
    },  
    methods: {     
        openApp() {     
            vscode.postMessage({
                type: 'openApp',
            });
            this.text = '';  
        },
        sendMessage() { 
            vscode.postMessage({
                type: 'message',
                value: this.text,
            }); 
            this.text = '';  
        },
        receiveMessage(event) {
            if (!event) return;    
            
            const envelope = event.data;
            switch (envelope.command) {
                case 'message': { 
                    this.message = envelope.data;  
                    break;
                }
            };
        },
    },
}
</script>

<style scoped>
    p {
        margin: 10px 0; 
        padding: 5px 0;
        font-size: 1.2rem;
    }
    span {  
        display: inline-block;
        margin-top: 5px;  
        font-size: 1rem;
        color: orange;
    }
    hr {
        display: inline-block;  
        width: 100%;  
        margin: 10px 0;
    }
</style>

Navigate to the extension root directory and add a new rollup.config.js file.

Listing 5 shows the complete content of this file.

Listing 5: rollup.config.js

import path from "path";
import fs from "fs";

import alias from '@rollup/plugin-alias';
import commonjs from 'rollup-plugin-commonjs';
import esbuild from 'rollup-plugin-esbuild';
import filesize from 'rollup-plugin-filesize';
import image from '@rollup/plugin-image';
import json from '@rollup/plugin-json';
import postcss from 'rollup-plugin-postcss';
import postcssImport from 'postcss-import';
import replace from '@rollup/plugin-replace';
import resolve from '@rollup/plugin-node-resolve';
import requireContext from 'rollup-plugin-require-context';
import { terser } from 'rollup-plugin-terser';
import vue from 'rollup-plugin-vue';

const production = !process.env.ROLLUP_WATCH;

const postCssPlugins = [  
    postcssImport(),
];

export default fs  
    .readdirSync(path.join(__dirname, "web", "pages"))  
    .map((input) => {   
        const name = input.split(".")[0].toLowerCase();  
        return {     
            input: `web/pages/${input}`,     
            output: {
                file: `dist-web/${name}.js`,
                format: 'iife',
                name: 'app',
                sourcemap: false,    
            },
            plugins: [
                commonjs(),
                json(),
                alias({
                    entries: [{ find: '@',
                    replacement: __dirname + '/web/' }],
                }),
                image(),
                postcss({ extract: `${name}.css`,
                    plugins: postCssPlugins 
                }),
                requireContext(),
                resolve({  
                    jsnext: true,  
                    main: true, 
                    browser: true,  
                    dedupe: ["vue"],
                }),
                vue({ 
                    css: false
                }),
                replace({ 
                    'process.env.NODE_ENV': production ? 
                        '"production"' : '"development"',  
                    preventAssignment: true,
                }),
                esbuild({ 
                    minify: production, 
                    target: 'es2015',
                }),
                production && terser(),
                production && filesize(),
            ],
            watch: {
                clearScreen: false,
                exclude: ['node_modules/**'],     
            },
        };
    });

The most important section of this file:

export default fs  
    .readdirSync(      
        path.join(__dirname, "web", "pages")   
    )
    .map((input) => {
        const name = input.split(".")[0].toLowerCase();
        
    return {     
        input: `web/pages/${input}`,
        output: {
            file: `dist-web/${name}.js`,    
            format: 'iife',
            name: 'app',
},
...

The code snippet iterates over all the *.js pages inside the /web/pages/ directory and compiles each page separately into a new JavaScript file inside the /dist-web/ directory.

Let's install the Concurrently NPM package by running the following command:

npm i --save-dev concurrently

Then, locate the package.json file and replace the watch script with this:

"watch": "concurrently \"rollup -c -w\"
                       \"webpack --watch\"",

The watch script now compiles both the Vue.js pages and the extension files every time you change any file in both folders.

Run this command to compile both apps and generate the /dist-web/ directory:

npm run watch

You can now see four new files created inside the /dist-web/ directory:

  • app.js
  • app.css
  • sidebar.js
  • sidebar.css

Every page generates two files, specifically the JavaScript and CSS files.

That's it for now! The Vue.js pages are ready for hosting inside a Webview.

Step 5

Let's start first by copying the WebAppPanel.ts file from the extension project that uses the Vue CLI. Then you change the resource files to include both /dist-web/app.js and /dist-web/app.css.

Listing 6 shows the entire source code of this file after the changes.

Listing 6: WebAppPanel.ts loading single Vue.js Root Component

import * as vscode from "vscode";
import { getNonce } from "./getNonce";

export class WebAppPanel {
    public static currentPanel: WebAppPanel | undefined;

    public static readonly viewType = "vscodevuerollup:panel";

    private readonly _panel: vscode.WebviewPanel; 
    private readonly _extensionUri: vscode.Uri;   
    private _disposables: vscode.Disposable[] = [];

    public static createOrShow(extensionUri: vscode.Uri) {      
        const column = vscode.window.activeTextEditor?
        vscode.window.activeTextEditor.viewColumn: undefined;

        // If we already have a panel, show it.
        if (WebAppPanel.currentPanel) {
            WebAppPanel.currentPanel._panel.reveal(column);
            return;      
        }

        // Otherwise, create a new panel.  
        const panel = vscode.window.createWebviewPanel(
            WebAppPanel.viewType,
            'Web App Panel',
            column || vscode.ViewColumn.One,
            getWebviewOptions(extensionUri),      
        );

        WebAppPanel.currentPanel = new WebAppPanel(panel, extensionUri);    
    }

    public static kill() {    
        WebAppPanel.currentPanel?.dispose();
        WebAppPanel.currentPanel = undefined; 
    }

    public static revive(panel: vscode.WebviewPanel,
        extensionUri: vscode.Uri) {
            WebAppPanel.currentPanel = new WebAppPanel(panel, extensionUri);  
        }
        
    private constructor(panel: vscode.WebviewPanel,
        extensionUri: vscode.Uri) {
            this._panel = panel;
            this._extensionUri = extensionUri;

            // Set the webview's initial html content
            this._update();
            this._panel.onDidDispose(() => this.dispose(), 
                null, this._disposables);

            // Update the content based on view changes 
            this._panel.onDidChangeViewState(  
                e => {
                    if (this._panel.visible) {
                        this._update();}
                },
                null,
                this._disposables    
            );
            
            // Handle messages from the webview
            this._panel.webview.onDidReceiveMessage(    
                message => {
                    switch (message.command) {
                        case 'alert': vscode.window.showErrorMessage(message.text);  
                        return;
                    }
                },
                null,
                this._disposables
            );
        }

        public dispose() {    
            WebAppPanel.currentPanel = undefined; 
            
            // Clean up our resources 
            this._panel.dispose();

            while (this._disposables.length) {      
                const x = this._disposables.pop();
                if (x) {
                    x.dispose();    
                }
            }
        }

        private async _update() { 
            const webview = this._panel.webview;     
            this._panel.webview.html = this._getHtmlForWebview(webview);  
        }

        private _getHtmlForWebview(webview: vscode.Webview) {
            const styleResetUri = webview.asWebviewUri(
                vscode.Uri.joinPath(this._extensionUri, "media", "reset.css")
            );
            const styleVSCodeUri = webview.asWebviewUri(      
                vscode.Uri.joinPath(
                    this._extensionUri, "media", "vscode.css")
            );

            const scriptUri = webview.asWebviewUri(
                vscode.Uri.joinPath(
                    this._extensionUri, "dist-web", "app.js")
            );
            
            const styleMainUri = webview.asWebviewUri(
                vscode.Uri.joinPath(
                    this._extensionUri, "dist-web", "app.css")    
            );

            const nonce = getNonce();
            
            return `      
                <!DOCTYPE html>
                <html lang="en">     
                <head>
                    <meta charset="utf-8" />
                    <meta name="viewport" content="width=device-width, 
                                                         initial-scale=1" />
                    <link href="${styleResetUri}" rel="stylesheet">
                    <link href="${styleVSCodeUri}" rel="stylesheet">
                    <link href="${styleMainUri}" rel="stylesheet">
                    <title>Web Pages Panel</title>  
                </head> 
                <body>
                    <div id="app"></div>
                    <script src="${scriptUri}" nonce="${nonce}">
                </body>
                </html> 
            `;
        }
}

function getWebviewOptions(extensionUri: vscode.Uri): vscode.WebviewOptions {
    return {
        // Enable javascript in the webview
        enableScripts: true,

        localResourceRoots: [
            vscode.Uri.joinPath(extensionUri, 'media'), 
            vscode.Uri.joinPath(extensionUri, 'dist-web'),
        ]
    };
}

Add a new /src/SidebarProvider.ts file and paste the contents of Listing 7 inside it.

Listing 7: SidebarProvider.ts

import * as vscode from "vscode";
import { getNonce } from "./getNonce";

export class SidebarProvider implements vscode.WebviewViewProvider {
    public static readonly viewType = 'vscodevuerollup:sidebar';

    private _view?: vscode.WebviewView;

    constructor(private readonly _extensionUri: vscode.Uri) {}

    public resolveWebviewView(    
        webviewView: vscode.WebviewView,    
        context: vscode.WebviewViewResolveContext,
            _token: vscode.CancellationToken  
    ) {
        this._view = webviewView;

    webviewView.webview.options = {      
        // Allow scripts in the webview
        enableScripts: true,
        
        localResourceRoots: [
            this._extensionUri
        ],
    };

    webviewView.webview.html = this._getHtmlForWebview(webviewView.webview);

    webviewView.webview.onDidReceiveMessage(async (data) => {     
        switch (data.type) {
            case "message": {
                if (!data.value) {
                    return;
                }
                vscode.window.showInformationMessage(data.value);  
                break;
            }
            case "openApp": {  
                await vscode.commands.executeCommand(
                    'vscodevuerollup:openVueApp', { ...data }
                );
                break;
            }
            case "onInfo": {
                if (!data.value) {
                    return; 
                }
                vscode.window.showInformationMessage(data.value);  
                break;
            }
            case "onError": {
                if (!data.value) { 
                    return; 
                } 
                vscode.window.showErrorMessage(data.value); 
                break;
            }
        }
    });
    }

    public revive(panel: vscode.WebviewView) {
        this._view = panel; 
    }

    public sendMessage() {
        return vscode.window.showInputBox({
            prompt: 'Enter your message',
            placeHolder: 'Hey Sidebar!'
        }).then(value => {      
            if (value) {
                this.postWebviewMessage({  
                    command: 'message',  
                    data: value,});      
            }
        });
    }
    private postWebviewMessage(msg: {
        command: string,
        data?: any
    }) {
    vscode.commands.executeCommand(
                    'workbench.view.extension.vscodevuerollup-sidebar-view');  
    vscode.commands.executeCommand('workbench.action.focusSideBar');
    
    this._view?.webview.postMessage(msg); 
    }  

    private _getHtmlForWebview(webview: vscode.Webview) 
    { 
        const styleResetUri = webview.asWebviewUri(
            vscode.Uri.joinPath(
                this._extensionUri, "media", "reset.css")    
        );

        const styleVSCodeUri = webview.asWebviewUri(      
            vscode.Uri.joinPath(
                this._extensionUri, "media", "vscode.css")    
        );

        const scriptUri = webview.asWebviewUri(      
            vscode.Uri.joinPath(
                this._extensionUri, "dist-web", "sidebar.js")    
        );
        
        const styleMainUri = webview.asWebviewUri( 
            vscode.Uri.joinPath(
                this._extensionUri, "dist-web", "sidebar.css")   
        );

        const nonce = getNonce();

    return `
        <!DOCTYPE html>
        <html lang="en">
        <head>
            <meta charset="utf-8" />
            <meta name="viewport" content="width=device-width, 
                                                 initial-scale=1" />  
                <link href="${styleResetUri}" rel="stylesheet">
                <link href="${styleVSCodeUri}" rel="stylesheet">
                <link href="${styleMainUri}" rel="stylesheet">  
                <title>Web Pages Panel</title>
                <script nonce="${nonce}">    
                    const vscode = acquireVsCodeApi();
                </script>
        </head>     
        <body>
            <div id="app"></div>
            <script src="${scriptUri}" nonce="${nonce}">
        </body>
        </html>   
    `;
    }
}

The SidebarProvider implements the WebviewViewProvider interface. It wraps an instance of the WebviewView that, in turn, wraps a Webview that holds the actual HTML content.

The resolveWebviewView() function sits at the core of this provider. It's used by VS Code to load the Webview into the Sidebar. It's in this function that you set the HTML content of the Webview for VS Code to display it inside the Sidebar. The provider loads both resource files /dist-web/sidebar.js and /dist-web/sidebar.css inside the HTML.

The HTML of this Webview now contains the following code:

<script>       
    const vscode = acquireVsCodeApi();
</script>

The vscode object will be the bridge that the Vue.js app can use to post messages to the extension.

That's it! Let's run the extension by pressing the F5 key. A new instance of the VS Code opens.

Locate and click the last icon added on the Activity Bar. Figure 11 shows how the Sidebar.vue Component is loaded inside the Sidebar section.

Figure 11: Sidebar.vue component inside the Sidebar
Figure 11: Sidebar.vue component inside the Sidebar

Step 6

Let's load the App.vue component inside an editor when the user clicks the Open button on the Sidebar.

Go to the /web/components/Sidebar.vue file and bind the button to an event handler:

<button @click="openApp">Open</button>

Then, define the openApp() function as follows:

openApp() {
    vscode.postMessage({
        type: 'openApp',   
    });
},

The code uses the vscode.postMessage() function to post a message to the extension by passing a message payload. In this case, the payload specifies the type of the message only.

Switch to the SidebarProvider.ts file and inside the resolveWebviewView() function listen to the message type you've just defined. You listen to posted messages inside the onDidReceiveMessage() function as follows:

webviewView.webview.onDidReceiveMessage(
    async (data) => {
        switch (data.type) {
            case "openApp": {
                await vscode.commands.executeCommand(
                        'vscodevuerollup:openVueApp',
                        { ...data }
                    );
                break;
            }
            // more
        }
    }
);

When the user clicks the Open button on the Sidebar, the provider reacts by executing the command vscodevuerollup:openVueApp and passing over a payload (if needed).

That's it! Let's run the extension by pressing the F5 key. A new instance of the VS Code opens.

Click the last icon added on the Activity Bar. Then click the Open button. Figure 12 shows the App.vue component loaded inside a Webview on the editor. The Sidebar.vue component is loaded inside a Webview on the Sidebar.

Figure 12: Sidebar.vue and App.vue components inside VS Code extension
Figure 12: Sidebar.vue and App.vue components inside VS Code extension

The Webview API allows two-way communication between the extension and the HTML content.

Step 7

Let's add a command to allow the extension to post a message to the Sidebar.vue component from within VS Code.

Start by defining the vscodevuerollup:sendMessage command inside the package.json file as follows:

{   
    "command": "vscodevuerollup:sendMessage",   
    "title": "Send message to sidebar panel",  
    "category": "Vue Rollup"
}

Then, register this command inside the extension.ts file:

context.subscriptions.push(
    vscode.commands.registerCommand(
        'vscodevuerollup:sendMessage', async () => {
            if (sidebarProvider) { 
                await sidebarProvider.sendMessage();
            }
        }
    )
);

The command handler calls the sendMessage() instance function on the SidebarProvider class when the user triggers the sendMessage command.

Listing 8 shows the sendMessage() function. It prompts the user for a message via the built-in vscode.window.showInputBox() function. The message the user enters is then posted to the Sidebar.vue component using the Webview.postMessage() built-in function.

Listing 8: sendMessage() function

public sendMessage() { 
    return vscode.window.showInputBox({
        prompt: 'Enter your message',
        placeHolder: 'Hey Sidebar!'}
    ).then(value => {   
        if (value) {
            this._view?.webview.postMessage({  
                command: 'message', 
                data: value,
            });
        }
    });
}

Sidebar.vue component handles the message received from the extension by registering an event listener as follows:

mounted() {
    window.addEventListener(
        'message', this.receiveMessage
    );
},

The receiveMessage() function runs when the user triggers the command inside VS Code.

You define the receiveMessage() function as follows:

receiveMessage(event) {
    if (!event) return;

    const envelope = event.data;
    switch (envelope.command) { 
        case 'message': {  
            this.message = envelope.data;  
            break;
        }
    };
},

It validates the command to be of type message. It then extracts the payload of the command and assigns it to a local variable that the component displays on the UI.

Let's run the extension!

Locate and navigate to the Sidebar.vue component hosted inside the Sidebar.

Open the Command Palette, start typing “Send message to sidebar panel”. VS Code prompts you for a message, as shown in Figure 13. Enter any message of your choice and hit Enter.

Figure 13: Promoting the user for input
Figure 13: Promoting the user for input

The message will be displayed on the Sidebar, as shown in Figure 14.

Figure 14: The Sidebar.vue component receives a message from the extension.
Figure 14: The Sidebar.vue component receives a message from the extension.

Congratulations! You've completed your third VS Code extension so far.

Conclusion

You can see how dissecting the VS Code helps your greater understanding of how it works. Once you can grasp the basic behavior of each component, it becomes an easier task to manipulate the code to make it do what you need it to.

This is just the beginning of a new series of articles on extending VS Code by building more extensions. In the coming articles, the plan is to expand the functionality of extensions and connect to remote REST APIs, databases and much more.

Stay tuned!