The correct way to create a VS Code Touch Bar Extension

Posted

This is part two, deploying a Visual Studio Code touchbar extension for Markdown notes, using the normal “correct” development method. I’d suggest referring to part one, for my much simpler, zero-code method.

Touchbar

For a change this time, I am not using any icons, but instead using Unicode text characters, symbols and emojis!

Custom Touchbar Extension for Markdown files

Custom Touchbar Extension for other files

Important: this is a tutorial to make and customize your own extension. It is not available in the VS Code Marketplace. The reason is this: if you want to change the touchbar items, you need to manually one of the source files!

Pre-requisites

I’m not going into details this time, just note you need:

  • Markdown Extended extension
  • ideally, the setting keyboard.touchbar.ignored setup to maximize the usable touchbar space, in either in settings.json or your workspace’s .vscode/settings.json:
"keyboard.touchbar.ignored": [
    "workbench.action.navigateBack",
    "workbench.action.navigateForward",
    "workbench.action.debug.start",
    "workbench.action.debug.run"
]
  • this time, I am not using icons, so you can skip getting and editing the PNG icons.

Creating the custom Touchbar Extension

The shortcut method was manually creating a few files in ~/.vscode/extensions - this time at minimum you need extension.js and package.json.

However, the correct way to create an extension is to install Yeoman and run the VS Code Extension Generator. I do it in a Docker container:

  1. Setup a shared Docker folder and run docker run --rm -it -v <folder>:/home/node node bash
  2. In the container, run:
    npm install -g yo generator-code
    su node
    cd ~
    yo code
  3. Fill in the details similar to below:
     _-----_     ╭──────────────────────────╮
    |       |    │   Welcome to the Visual  │
    |--(o)--|    │   Studio Code Extension  │
   `---------´   │        generator!        │
    ( _´U`_ )    ╰──────────────────────────╯
    /___A___\   /
     |  ~  |     
   __'.___.'__   
 ´   `  |° ´ Y ` 

? What type of extension do you want to create? New Extension (JavaScript)
? What's the name of your extension? MyByways Touchbar
? What's the identifier of your extension? mybyways-touchbar
? What's the description of your extension? 
? Enable JavaScript type checking in 'jsconfig.json'? No
? Initialize a git repository? No
? Which package manager to use? npm

Writing in /home/node/mybyways-touchbar...
...
Your extension mybyways-touchbar has been created!
  1. And... the source files will be created. You can exit the Docker container now (exit twice).
  2. Edit extension.js and package.json - source code for both are further down.
  3. At this stage, you can actually open the Workspace and debug the extension in VS Code!
  4. But, to deploy the extension so that it the extension automatically when VS Code starts, first create a new folder for the extension and then copy all files into it, e.g. create ~/.vscode/extensions/mybyways.touchbar-1.0.0/ if you want to follow the standard naming convention.
  5. Now you can re-start VS Code to test.

Anatomy of a Touchbar Extension

There are only 2 mandatory files:

The manifest contains every parameter to configure the touchbar and commands:

When the extension is first run, VS code reads contributes.menus.touchBar and displays the touchbar items (button). The first time any touchbar item (button) is pressed, VS code triggers an onCommand event as in the activationEvents section to activate the extension. This should happen only once.

Now the code in the activate() function in extension.js gets executed, to register (binds) all the extension’s new commands (defined in the contributes.commands section and prefixed with mybyways-touchbar) to functions. In all cases, the functions will simply execute another command, and this is specified in the contributes.configuration section of the manifest. Only now can the command associated with the touchbar item be triggered.

As an example, assuming this is the first touchbar item pressed: pressing "B" for bold triggers onCommand:mybyways-touchbar.toggleBold, which triggers activate(), which reads contributes.configuration and registers all commands including the mybyways-touchbar.toggleBold command, which in turn executes the existing command markdownExtended.toggleBold.

package.json

The Extension Manifest, at minimum (read: un-publishable), contains:

{
    "name": "mybyways-touchbar",
    "displayName": "MyByways Touchbar",
    "publisher": "MyByways.com",
    "version": "0.0.1",
    "engines": { "vscode": "^1.50.0" },
    "main": "./extension.js",
    "activationEvents": [
        "onCommand:mybyways-touchbar.toggleBold",
        "onCommand:mybyways-touchbar.toggleItalics",
        "onCommand:mybyways-touchbar.toggleUList",
        "onCommand:mybyways-touchbar.toggleOList",
        "onCommand:mybyways-touchbar.toggleMark",
        "onCommand:mybyways-touchbar.toggleStrikethrough",
        "onCommand:mybyways-touchbar.toggleUnderline",
        "onCommand:mybyways-touchbar.toggleSuperscript",
        "onCommand:mybyways-touchbar.toggleSubscript",

        "onCommand:mybyways-touchbar.indentLines",
        "onCommand:mybyways-touchbar.outdentLines",
        "onCommand:mybyways-touchbar.nextMatchFindAction",
        "onCommand:mybyways-touchbar.previousMatchFindAction",
        "onCommand:mybyways-touchbar.toggleMinimap",
        "onCommand:mybyways-touchbar.toggleWordWrap",
        "onCommand:mybyways-touchbar.quickFix",

        "onCommand:mybyways-touchbar.showCommands",
        "onCommand:mybyways-touchbar.togglePanel",
        "onCommand:mybyways-touchbar.toggleSidebarVisibility",
        "onCommand:mybyways-touchbar.files.newUntitledFile",
        "onCommand:mybyways-touchbar.editor.changeLanguageMode"
    ],
    "contributes": {
        "commands": [
            { "title": "𝐁",  "command": "mybyways-touchbar.toggleBold" },
            { "title": "𝑖",   "command": "mybyways-touchbar.toggleItalics" },
            { "title": "•",  "command": "mybyways-touchbar.toggleUList" },
            { "title": "⒈",  "command": "mybyways-touchbar.toggleOList" },
            { "title": "=", "command": "mybyways-touchbar.toggleMark" },
            { "title": "–",  "command": "mybyways-touchbar.toggleStrikethrough" },
            { "title": "_",  "command": "mybyways-touchbar.toggleUnderline" },
            { "title": "¹",  "command": "mybyways-touchbar.toggleSuperscript" },
            { "title": "₂",  "command": "mybyways-touchbar.toggleSubscript" },

            { "title": "⇥", "command": "mybyways-touchbar.indentLines" },
            { "title": "⇤", "command": "mybyways-touchbar.outdentLines" },
            { "title": "🔎", "command": "mybyways-touchbar.nextMatchFindAction" },
            { "title": "🔍", "command": "mybyways-touchbar.previousMatchFindAction" },
            { "title": "◨",  "command": "mybyways-touchbar.toggleMinimap" },
            { "title": "↩",  "command": "mybyways-touchbar.toggleWordWrap" },
            { "title": "⎀",  "command": "mybyways-touchbar.quickFix" },

            { "title": ">_", "command": "mybyways-touchbar.showCommands" },
            { "title": "⬓",  "command": "mybyways-touchbar.togglePanel" },
            { "title": "◧",  "command": "mybyways-touchbar.toggleSidebarVisibility" },
            { "title": "New", "command": "mybyways-touchbar.files.newUntitledFile" },
            { "title": "✍︎",  "command": "mybyways-touchbar.editor.changeLanguageMode" }
        ],
        "configuration": {
            "title": "MyByways Touchbar",
            "properties": {
              "mybyways-touchbar.commands": {
                "type": "object",
                "description": "Command ID Prefixes and Names",
                "default": {
                    "markdownExtended": ["toggleBold", "toggleItalics", "toggleUList", "toggleOList", "toggleMark", "toggleStrikethrough", "toggleUnderline", "toggleSuperscript", "toggleSubscript"],
                    "workbench.action": ["showCommands", "togglePanel", "toggleSidebarVisibility","files.newUntitledFile","editor.changeLanguageMode"],
                    "editor.action": ["indentLines", "outdentLines", "nextMatchFindAction","previousMatchFindAction","toggleMinimap","toggleWordWrap","quickFix"]
                }
              }
            }
        },
        "menus": {
            "touchBar": [
                { "command": "mybyways-touchbar.toggleBold",    "group": "a@1", "when":"editorLangId==markdown" },
                { "command": "mybyways-touchbar.toggleItalics", "group": "a@2", "when":"editorLangId==markdown" },
                { "command": "mybyways-touchbar.toggleMark",    "group": "a@3", "when":"editorLangId==markdown" },
                { "command": "mybyways-touchbar.toggleUList",   "group": "a@4", "when":"editorLangId==markdown" },
                { "command": "mybyways-touchbar.toggleOList",   "group": "a@5", "when":"editorLangId==markdown" },

                { "command": "mybyways-touchbar.showCommands",              "group": "b@1"},
                { "command": "mybyways-touchbar.togglePanel",               "group": "b@2" },
                { "command": "mybyways-touchbar.files.newUntitledFile",     "group": "b@3", "when": "!editorIsOpen" },
                { "command": "mybyways-touchbar.nextMatchFindAction",       "group": "b@4", "when": "editorIsOpen"  },
                { "command": "mybyways-touchbar.previousMatchFindAction",   "group": "b@5", "when": "editorIsOpen&&editorLangId!=markdown"},
                { "command": "mybyways-touchbar.editor.changeLanguageMode", "group": "b@6", "when": "editorLangId==plaintext" },

                { "command": "mybyways-touchbar.quickFix",       "group": "c@1", "when": "editorHasCodeActionsProvider" },
                { "command": "mybyways-touchbar.indentLines",    "group": "c@2", "when": "editorIsOpen" },
                { "command": "mybyways-touchbar.toggleWordWrap", "group": "c@3", "when": "editorIsOpen" }
            ]
        }
    }
}

Activation Events

The only activation event required is onCommand. All commands actually shown in the touchbar via the contributes.menu.touchbar configuration must have a corresponding onCommand event.

Commands

The commands section contains any (and all) items that could appear in the touchbar. What I show above is a superset of some items that are relevant to me. These can be built-in commands e.g. editor.action.* or commands contributed by extensions like markdownExtended.*.

Each command comprises an icon, a title and a command ID:

{ 
    "title": "𝐁", 
    "command": "mybyways-touchbar.toggleBold"
},

To add more commands, simply add a line with the command title (unicode characters work fine) and command ID. Here’s a tip to determine an existing command ID:

  • open Code > Preferences > Keyboard Shortcuts
  • search for a command, e.g. "bold" and once found...
  • right-click on the item and Copy Command ID.

Keyboard Shortcuts to get command IDs

If you were to rename the title of an existing command, e.g. { "title": "𝐁", "command": "mybyways-touchbar.toggleBold" }, then you would end up with this in the Command Palette and Keyboard Shortcuts UI - this is bad because you can no longer search for the original command title:

VS Code Commands Overridden by an Extension

Configuration

Let me start of by saying there are no user configurable settings! I use the configuration section merely so that I do not have to ever edit extension.js source code even when adding new command IDs. Although you can provide overrides in settings.json, why bother, since you will have to edit the package.json manifest anyway.

In essence, I want to register a new command e.g. mybyways-touchbar.toggleBold which runs an existing command, e.g. markdownExtended.toggleBold. Another example is mybyways-touchbar.showCommands which runs the existing command workbench.action.showCommands. Notice that the last part of the command IDs must match, so the code shown later can just read this configuration and figure out the rest.

This is where the configuration resides, in a default section (truncated for readability), so change this part to add command IDs, you should be able to work out what goes where (note the arrays):

"default": {
    "markdownExtended": [ "toggleBold", "toggleItalics", "toggleUList", ...],
    "workbench.action": [ "showCommands", "togglePanel", "toggleSidebarVisibility", ...],
    "editor.action":    [ "indentLines", "outdentLines", "nextMatchFindAction", ...]
}

Menus

Finally, the menus.touchbar section specifies what items appear in the touchbar, and when.

Each touchbar item comprises:

  • a command ID, which much match a command in the previous section,
  • a group that breaks up the touchbar items into sections
    • by default, touchbar items are sorted by the command title - to change the order use the group attribute with the a@1, a@2, a@3... notation.
    • touchbar groups are also sorted by the group name, hence, I start mine with a, b, c... so that the markdown group named a@* appears before b@*, etc.
  • and optionally, when which is the condition wherein the touchbar item is visible i.e. visible when the document language is Markdown:
{ 
    "command": "mybyways-touchbar.toggleBold", 
    "group": "a@1", 
    "when":"editorLangId==markdown"
},

On the touchbar on a 16" MacBook Pro, I can display only about 11 items, with the keyboard.touchbar.ignored setting, depending on the width of team item (text or icon) and the number of groups. If the items exceed the space available on the touchbar, then the entire group will not be shown. You have to experiment.

You may be interested to know the standard two groups are named navigation and 9_debug - see the VS Code source code, editor.contribution.ts and debug.contribution.ts respectively.

extension.js

In the commands section, each new command added by this extension will simply execute an existing commands. You can trigger any built-in or extension commands. For example:

let c = vscode.commands.registerCommand('mybyways-touchbar.toggleBold', function() {
    vscode.commands.executeCommand('markdownExtended.toggleBold');
});
context.subscriptions.push(c);

As explained, I want to avoid having to change any source code when adding new commands, preferring to make any changes in the Extension Manifest. To do this, the code reads the configuration section using getConfiguration() and then does registerCommand() for each new command added. Pardon the un-readable code:

const vscode = require('vscode');
/**
 * @param {vscode.ExtensionContext} context
 */
 function activate(context) { 
    const cmd = vscode.commands, 
        sub = context.subscriptions, 
        cfg = vscode.workspace.getConfiguration('mybyways-touchbar.commands');
    Object.keys(cfg).forEach(key=>{
        const val = cfg[key];
        if (Array.isArray(val)) val.forEach(itm=>{
            sub.push(cmd.registerCommand("mybyways-touchbar." + itm, ()=>{ 
                cmd.executeCommand(key + "." + itm)
            }))
        })
    })
}
function deactivate() {}
module.exports = { activate, deactivate }

launch.json

If you create these files manually, and want to run and debug the extension in VS Code, then you will need to create one more file. .vscode/launch.json. This tells VS Code to run it as an extension. You need to open the folder as a Workspace:

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Run Extension",
            "type": "extensionHost",
            "request": "launch",
            "args": [ "--extensionDevelopmentPath=${workspaceFolder}" ]
        }
    ]
}

Conclusion

To-recap:

  • you don’t really need node or npm installed, running them from a Docker container works just fine,
  • you can run and debug the extension from VS Code, nothing else needed,
  • to add a new command to an extension - add it to the Configuration (contributes.configuration) and Commands (contributes.commands) sections.
  • to add a new item to the touchbar: add it to the Menus (contributes.menus.touchBar) section and make sure there is also an onCommand in the Activation Events (activationEvents) section.
  • using a default configuration, there should be no need to edit the code in extension.js - I use a couple of tricks to parse the entire JSON configuration section.

I’d say this is the officially “correct” way to create a touchbar extension, and customize it to meet your needs. But why bother when my shortcut method works so well!

Alas, I have no intention of ever creating a proper Marketplace extension. Good luck!

Credit: The Nasc VSCode Touchbar inspired me to investigate my own improved version!