Building a VSCode Extension: Part Three

Building a VSCode Extension: Part Three

Now that I have a blank VS Code extension set up and working, I want to start building on it.

Adding some Code formatting configs

The Yeoman template for VS Code Extension does not have any formatting configs that I typically use for my projects.

I make sure to always have an .editorconfig file. EditorConfig is used to help maintain consistent coding styles for whitespace across everyone's text editors and IDEs. Here is an example I typically use on my typescript projects.

# .editorconfig
# top-most EditorConfig file
root = true

# Unix-style newlines with a newline ending every file
[*]
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

# Matches multiple files with brace expansion notation
# Set default charset
[*.{js,jsx,ts,tsx}]
charset = utf-8
indent_style = space
indent_size = 4

# Matches the exact files either package.json or .travis.yml
[package.json]
indent_style = space
indent_size = 2

Prettier adds even more code formatting. It really helps create a consistent code style. Every developer has a different way of implementing code. Having a consistent style is important for open source. Here is the .prettierrc config I am using for my extension.

{
    "printWidth": 160,
    "trailingComma": "none",
    "tabWidth": 4,
    "useTabs": false,
    "semi": true,
    "singleQuote": true,
    "jsxSingleQuote": true,
    "bracketSpacing": true
}

I work on multiple projects that all require a different node version. I use NVM along with AVN to auto switch my node version depending on which repository I am in. Example .node-version file used in this repository.

v12.18.3

With some consistency added to the code base, it's time to work on the react app.

Bootstrapping React

Creating a brand new react app is fairly simple using the create-react-app tool.

I knew I wanted the app in a subdirectory called webview in my extension. First I navigated to the src directory and then used create-react-app to set up an empty react app. I used the typescript template since I wanted this entire extension using typescript including the react portion.

cd src/
npx create-react-app webview --template typescript

Now I just wanted to verify everything was set up and working.

cd webview/
npm run start

It failed with this error...

There might be a problem with the project dependency tree.
It is likely not a bug in Create React App, but something you need to fix locally.

The react-scripts package provided by Create React App requires a dependency:

  "eslint": "^6.6.0"

Don't try to install it manually: your package manager does it automatically.
However, a different version of eslint was detected higher up in the tree:

  /home/CodeByCorey/workspace/vscode-todo-task-manager/node_modules/eslint (version: 7.7.0)

Manually installing incompatible versions is known to cause hard-to-debug issues.

If you would prefer to ignore this check, add SKIP_PREFLIGHT_CHECK=true to an .env file in your project.
That will permanently disable this message but you might encounter other issues.

To fix the dependency tree, try following the steps below in the exact order:

  1. Delete package-lock.json (not package.json!) and/or yarn.lock in your project folder.
  2. Delete node_modules in your project folder.
  3. Remove "eslint" from dependencies and/or devDependencies in the package.json file in your project folder.
  4. Run npm install or yarn, depending on the package manager you use.

In most cases, this should be enough to fix the problem.
If this has not helped, there are a few other things you can try:

  5. If you used npm, install yarn (http://yarnpkg.com/) and repeat the above steps with it instead.
     This may help because npm has known issues with package hoisting which may get resolved in future versions.

  6. Check if /home/CodeByCorey/workspace/vscode-todo-task-manager/node_modules/eslint is outside your project directory.
     For example, you might have accidentally installed something in your home folder.

  7. Try running npm ls eslint in your project folder.
     This will tell you which other package (apart from the expected react-scripts) installed eslint.

If nothing else helps, add SKIP_PREFLIGHT_CHECK=true to an .env file in your project.
That would permanently disable this preflight check in case you want to proceed anyway.

P.S. We know this message is long but please read the steps above :-) We hope you find them helpful!

I looked in the root package.json for my VS Code extension and it is using eslint@7 and react-scrips requires eslint@6. Due to how yarn/npm handles packages, my react app was not installing eslint at v6 because yarn already saw it installed at v7 at the root of the project.

The easiest solution I used was to downgrade my extension's eslint version on my root project.

# navigate back to the root of the project
cd ../../
yarn add -D eslint@6
cd src/webview
yarn start

Boom! It worked and opened my app in the browser at http://localhost:3000

I moved the extension.ts into its own directory to help keep the webview and extension separate.

mkdir -p src/extension
mv src/extension.ts src/extension/extension.ts

and changed the main key on the package.json to use the new folder structure

"main": "./dist/extension/extension.js"

How do I get VS Code to open it??

The react app is working in my browser but how do I make VS Code display it?

First thing I did was add the VS Code commands that would open the react app inside package.json

"activationEvents": [
    "onCommand:vscode-task-manager.openTodoManager"
],
"contributes": {
    "commands": [
        {
            "command": "vscode-task-manager.openTodoManager",
            "title": "Todo Manager"
        }
    ]
}

Inside extension.ts I replace the helloWorld command with my new command. Using the Webview docs I figured out how to open a panel with HTML.

import * as vscode from 'vscode';

export function activate(context: vscode.ExtensionContext) {
    context.subscriptions.push(
        vscode.commands.registerCommand('vscode-task-manager.openTodoManager', () => {
            // Create and show panel
            const panel = vscode.window.createWebviewPanel(
                'todoManager',
                'Todo Manager',
                vscode.ViewColumn.One,
                {
                    enableScripts: true
                }
            );

            // And set its HTML content
            panel.webview.html = getWebviewContent();
        })
    );
}

function getWebviewContent() {
    return `
        <!DOCTYPE html>
        <html lang="en">
            <head>
                <meta charset="UTF-8">
                <meta name="viewport" content="width=device-width, initial-scale=1.0">
                <title>Todo Task Manager</title>
            </head>
            <body>
                <h1>Hello TODO</h1>
            </body>
        </html>
    `;
}

When you run the extension and trigger the Todo Manager command, it should open a new panel that display Hello TODO;

Now lets figure out how to get my react resources loaded into the HTML.

I need to move my reacts compiled code into the dist directory for my extension to use. I created a npm script inside my react project to move the folder after its finished building using postbuild.

  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "postbuild": "rimraf ../../dist/webview && mv build ../../dist/webview",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
  }

The location of the extensions files on the file system is conveniently attached to the context parameter on the activate function. I passed the object to my getWebviewContent() function where I plan to fetch all the react resources.

React is nice enough to offer an asset-manifest.json to find out the name of all the compiled assets. Using path, context.extensionPath, and vscodes.Uri, we can map out the physical location of the compiled react scripts, and import them into the html with VS Codes resource tags.

function getWebviewContent(context: vscode.ExtensionContext): string {
    const { extensionPath } = context;

    const webviewPath: string = path.join(extensionPath, 'dist', 'webview');
    const assetManifest: AssetManifest = require(path.join(webviewPath, 'asset-manifest.json'));

    const main: string = assetManifest.files['main.js'];
    const styles: string = assetManifest.files['main.css'];
    const runTime: string = assetManifest.files['runtime-main.js'];
    const chunk: string = Object.keys(assetManifest.files).find((key) => key.endsWith('chunk.js')) as string;

    const mainUri: vscode.Uri = vscode.Uri.file(path.join(webviewPath, main)).with({ scheme: 'vscode-resource' });
    const stylesUri: vscode.Uri = vscode.Uri.file(path.join(webviewPath, styles)).with({ scheme: 'vscode-resource' });
    const runTimeMainUri: vscode.Uri = vscode.Uri.file(path.join(webviewPath, runTime)).with({ scheme: 'vscode-resource' });
    const chunkUri: vscode.Uri = vscode.Uri.file(path.join(webviewPath, chunk)).with({ scheme: 'vscode-resource' });

    return `
        <!DOCTYPE html>
        <html lang="en">
            <head>
                <meta charset="UTF-8">
                <meta name="viewport" content="width=device-width, initial-scale=1.0">
                <title>Todo Task Manager</title>
                <link rel="stylesheet" type="text/css" href="${stylesUri.toString(true)}">
            </head>
            <body>
                <div id="root"></div>
                <script crossorigin="anonymous" src="${runTimeMainUri.toString(true)}"></script>
                <script crossorigin="anonymous" src="${chunkUri.toString(true)}"></script>
                <script crossorigin="anonymous" src="${mainUri.toString(true)}"></script>
            </body>
        </html>
    `;
}

Now when I run the debugger for my extension and trigger the Todo Manager command. The React app appears as a VS Code Panel!!

Issues and Concerns with current implementation.

I am not 100% happy with this solution. I am not a fan of a sub npm package and managing the react build separately than the extension. A great example of why I dislike it is the eslint issue I didn't expect to happen. I also dislike how I have to compile the react app separately and then compile the extension to make it work. I need to work on my npm scripts to make it more seamless.

One benefit of treating it like a separate app is I can run react in my browser to quickly develop the front end portion and then test it out as a webview panel later.

This is all just a proof of concept for now. There is a more official way to implement web views that I plan on using now that I know it works.

Next steps

I need to figure out how to make the react app and the extension communicate with each other. I have seen some existing open source projects using RPC (Not sure what that is) but I have also seen some using a postMessage() && onMessage() method. Over the next couple of days, I'll be investigating what I can do and document my efforts.

I also want a more trendy name. Todo Task Manager is just not sitting well with me.

Source Code