Ides Editors

VS Code Extension Development: A Practical Guide

A hands-on guide to building VS Code extensions from scratch, covering the extension API, activation events, commands, webviews, and publishing.

VS Code Extension Development: A Practical Guide

VS Code is not just an editor — it is a platform. The extension ecosystem is why developers choose it over alternatives. Building extensions is how you solve workflow problems that no existing tool covers. Need a custom linter for your company's coding standards? An integration with your internal deployment system? A specialized file viewer? Extensions handle all of these.

I have built extensions for internal teams and the marketplace. The API is well-designed once you understand its patterns. This guide covers everything from scaffolding to publishing, with real code you can adapt.

Prerequisites

  • Node.js installed (v14+)
  • VS Code installed
  • Basic familiarity with the VS Code interface
  • Understanding of TypeScript or JavaScript (examples use JS with var style)
  • npm and command-line experience

Scaffolding an Extension

The official Yeoman generator creates the project structure:

npm install -g yo generator-code
yo code

Choose these options:

? What type of extension do you want to create? New Extension (JavaScript)
? What's the name of your extension? my-extension
? What's the identifier? my-extension
? What's the description? A helpful VS Code extension
? Enable JavaScript type checking? Yes
? Initialize a git repository? Yes
? Which package manager? npm

This creates:

my-extension/
  .vscode/
    launch.json       # Debug configuration
  extension.js        # Entry point
  package.json        # Extension manifest
  jsconfig.json       # JS/TS config
  .vscodeignore       # Files to exclude from package

The Extension Manifest

The package.json is the heart of your extension. It declares what the extension provides and when it activates.

{
  "name": "my-extension",
  "displayName": "My Extension",
  "description": "A helpful VS Code extension",
  "version": "0.0.1",
  "engines": {
    "vscode": "^1.80.0"
  },
  "categories": ["Other"],
  "activationEvents": [],
  "main": "./extension.js",
  "contributes": {
    "commands": [
      {
        "command": "myExtension.helloWorld",
        "title": "Hello World",
        "category": "My Extension"
      }
    ],
    "keybindings": [
      {
        "command": "myExtension.helloWorld",
        "key": "ctrl+shift+h",
        "mac": "cmd+shift+h"
      }
    ],
    "menus": {
      "editor/context": [
        {
          "command": "myExtension.helloWorld",
          "group": "myExtension"
        }
      ]
    },
    "configuration": {
      "title": "My Extension",
      "properties": {
        "myExtension.greeting": {
          "type": "string",
          "default": "Hello",
          "description": "The greeting to show"
        },
        "myExtension.showOnStartup": {
          "type": "boolean",
          "default": false,
          "description": "Show greeting when VS Code starts"
        }
      }
    }
  }
}

Activation Events

Extensions should activate lazily. Declare when your extension needs to wake up:

{
  "activationEvents": [
    "onCommand:myExtension.helloWorld",
    "onLanguage:javascript",
    "onLanguage:typescript",
    "workspaceContains:**/.eslintrc*",
    "onView:myExtension.sidebar",
    "onUri",
    "onStartupFinished"
  ]
}

Starting with VS Code 1.74, commands listed in contributes.commands automatically generate onCommand activation events, so you often do not need to specify them.

Extension Entry Point

The extension.js file exports activate and deactivate functions:

var vscode = require("vscode");

function activate(context) {
  console.log("My Extension is now active");

  // Register a command
  var disposable = vscode.commands.registerCommand(
    "myExtension.helloWorld",
    function() {
      var config = vscode.workspace.getConfiguration("myExtension");
      var greeting = config.get("greeting", "Hello");
      vscode.window.showInformationMessage(greeting + " from My Extension!");
    }
  );

  context.subscriptions.push(disposable);
}

function deactivate() {
  // Clean up resources
}

module.exports = { activate: activate, deactivate: deactivate };

The context.subscriptions array handles cleanup. Push disposables there and VS Code disposes them when the extension deactivates.

Working with the Editor

Reading and Modifying Text

// Get the active editor
var editor = vscode.window.activeTextEditor;
if (!editor) {
  vscode.window.showWarningMessage("No active editor");
  return;
}

var document = editor.document;
var selection = editor.selection;

// Read the entire document
var fullText = document.getText();

// Read the selected text
var selectedText = document.getText(selection);

// Read a specific line
var line = document.lineAt(5);
console.log(line.text);

// Modify text with an edit
editor.edit(function(editBuilder) {
  // Replace selection
  editBuilder.replace(selection, selectedText.toUpperCase());

  // Insert at position
  var position = new vscode.Position(0, 0);
  editBuilder.insert(position, "// Auto-generated header\n");

  // Delete a range
  var range = new vscode.Range(10, 0, 12, 0);
  editBuilder.delete(range);
});

Multi-Cursor Edits

vscode.commands.registerCommand("myExtension.wrapSelections", function() {
  var editor = vscode.window.activeTextEditor;
  if (!editor) return;

  editor.edit(function(editBuilder) {
    // Handle multiple selections (multi-cursor)
    for (var i = 0; i < editor.selections.length; i++) {
      var sel = editor.selections[i];
      var text = editor.document.getText(sel);
      editBuilder.replace(sel, "(" + text + ")");
    }
  });
});

Decorations

Add visual decorations to the editor:

var errorDecorationType = vscode.window.createTextEditorDecorationType({
  backgroundColor: "rgba(255, 0, 0, 0.1)",
  border: "1px solid red",
  borderRadius: "3px",
  after: {
    contentText: " ← issue here",
    color: "red",
    fontStyle: "italic"
  }
});

function updateDecorations(editor) {
  var decorations = [];
  var text = editor.document.getText();
  var regex = /TODO:/g;
  var match;

  while ((match = regex.exec(text)) !== null) {
    var startPos = editor.document.positionAt(match.index);
    var endPos = editor.document.positionAt(match.index + match[0].length);
    var range = new vscode.Range(startPos, endPos);

    decorations.push({
      range: range,
      hoverMessage: "This TODO needs attention"
    });
  }

  editor.setDecorations(errorDecorationType, decorations);
}

// Update when editor content changes
vscode.workspace.onDidChangeTextDocument(function(event) {
  var editor = vscode.window.activeTextEditor;
  if (editor && event.document === editor.document) {
    updateDecorations(editor);
  }
});

Status Bar Items

function createStatusBar(context) {
  var statusBar = vscode.window.createStatusBarItem(
    vscode.StatusBarAlignment.Right,
    100
  );

  statusBar.command = "myExtension.toggleFeature";
  statusBar.tooltip = "Click to toggle feature";

  function updateStatus() {
    var config = vscode.workspace.getConfiguration("myExtension");
    var enabled = config.get("enabled", true);

    if (enabled) {
      statusBar.text = "$(check) My Extension";
      statusBar.backgroundColor = undefined;
    } else {
      statusBar.text = "$(x) My Extension";
      statusBar.backgroundColor = new vscode.ThemeColor(
        "statusBarItem.warningBackground"
      );
    }

    statusBar.show();
  }

  updateStatus();

  // Update when config changes
  vscode.workspace.onDidChangeConfiguration(function(event) {
    if (event.affectsConfiguration("myExtension")) {
      updateStatus();
    }
  });

  context.subscriptions.push(statusBar);
}

Tree View Provider

Create a custom sidebar view:

function createTreeView(context) {
  var treeData = [
    { label: "Development", children: [
      { label: "Start Dev Server", command: "myExtension.startDev" },
      { label: "Run Tests", command: "myExtension.runTests" },
      { label: "Lint", command: "myExtension.lint" }
    ]},
    { label: "Deployment", children: [
      { label: "Deploy to Staging", command: "myExtension.deployStaging" },
      { label: "Deploy to Production", command: "myExtension.deployProd" }
    ]},
    { label: "Tools", children: [
      { label: "Generate Migration", command: "myExtension.genMigration" },
      { label: "Clear Cache", command: "myExtension.clearCache" }
    ]}
  ];

  var treeProvider = {
    _onDidChangeTreeData: new vscode.EventEmitter(),

    getTreeItem: function(element) {
      var item = new vscode.TreeItem(
        element.label,
        element.children
          ? vscode.TreeItemCollapsibleState.Expanded
          : vscode.TreeItemCollapsibleState.None
      );

      if (element.command) {
        item.command = {
          command: element.command,
          title: element.label
        };
        item.iconPath = new vscode.ThemeIcon("play");
      } else {
        item.iconPath = new vscode.ThemeIcon("folder");
      }

      return item;
    },

    getChildren: function(element) {
      if (!element) return treeData;
      return element.children || [];
    },

    refresh: function() {
      treeProvider._onDidChangeTreeData.fire();
    }
  };

  Object.defineProperty(treeProvider, "onDidChangeTreeData", {
    get: function() { return treeProvider._onDidChangeTreeData.event; }
  });

  var treeView = vscode.window.createTreeView("myExtension.sidebar", {
    treeDataProvider: treeProvider
  });

  context.subscriptions.push(treeView);
  return treeProvider;
}

Register the view in package.json:

{
  "contributes": {
    "views": {
      "explorer": [
        {
          "id": "myExtension.sidebar",
          "name": "My Extension"
        }
      ]
    },
    "viewsContainers": {
      "activitybar": [
        {
          "id": "myExtensionContainer",
          "title": "My Extension",
          "icon": "resources/icon.svg"
        }
      ]
    }
  }
}

Webview Panels

For rich UIs, use webviews:

function createDashboardPanel(context) {
  var panel = vscode.window.createWebviewPanel(
    "myExtension.dashboard",
    "Dashboard",
    vscode.ViewColumn.One,
    {
      enableScripts: true,
      retainContextWhenHidden: true,
      localResourceRoots: [
        vscode.Uri.joinPath(context.extensionUri, "media")
      ]
    }
  );

  // Get paths for local resources
  var styleUri = panel.webview.asWebviewUri(
    vscode.Uri.joinPath(context.extensionUri, "media", "style.css")
  );

  panel.webview.html = getWebviewContent(styleUri);

  // Handle messages from the webview
  panel.webview.onDidReceiveMessage(function(message) {
    switch (message.command) {
      case "deploy":
        vscode.commands.executeCommand("myExtension.deploy", message.target);
        break;
      case "showInfo":
        vscode.window.showInformationMessage(message.text);
        break;
    }
  });

  // Send data to the webview
  panel.webview.postMessage({
    command: "updateStats",
    data: { tests: 42, coverage: 87, builds: 5 }
  });

  return panel;
}

function getWebviewContent(styleUri) {
  return '<!DOCTYPE html>\n' +
    '<html>\n' +
    '<head>\n' +
    '  <link rel="stylesheet" href="' + styleUri + '">\n' +
    '</head>\n' +
    '<body>\n' +
    '  <h1>Project Dashboard</h1>\n' +
    '  <div id="stats"></div>\n' +
    '  <button onclick="deploy(\'staging\')">Deploy Staging</button>\n' +
    '  <script>\n' +
    '    var vscode = acquireVsCodeApi();\n' +
    '    function deploy(target) {\n' +
    '      vscode.postMessage({ command: "deploy", target: target });\n' +
    '    }\n' +
    '    window.addEventListener("message", function(event) {\n' +
    '      var msg = event.data;\n' +
    '      if (msg.command === "updateStats") {\n' +
    '        document.getElementById("stats").innerHTML =\n' +
    '          "Tests: " + msg.data.tests + " | Coverage: " + msg.data.coverage + "%";\n' +
    '      }\n' +
    '    });\n' +
    '  </script>\n' +
    '</body>\n' +
    '</html>';
}

File System and Workspace

// Watch for file changes
var watcher = vscode.workspace.createFileSystemWatcher("**/*.{js,ts}");

watcher.onDidChange(function(uri) {
  console.log("File changed: " + uri.fsPath);
});

watcher.onDidCreate(function(uri) {
  console.log("File created: " + uri.fsPath);
});

watcher.onDidDelete(function(uri) {
  console.log("File deleted: " + uri.fsPath);
});

// Find files in workspace
vscode.workspace.findFiles("**/*.test.js", "**/node_modules/**").then(function(uris) {
  console.log("Found " + uris.length + " test files");
});

// Read a file
vscode.workspace.fs.readFile(vscode.Uri.file("/path/to/file.json")).then(function(data) {
  var content = Buffer.from(data).toString("utf8");
  var json = JSON.parse(content);
});

// Write a file
var encoder = new TextEncoder();
var data = encoder.encode("file content here");
vscode.workspace.fs.writeFile(vscode.Uri.file("/path/to/output.txt"), data);

Testing Extensions

// test/extension.test.js

var assert = require("assert");
var vscode = require("vscode");

suite("Extension Test Suite", function() {
  vscode.window.showInformationMessage("Start all tests.");

  test("Extension should be present", function() {
    assert.ok(vscode.extensions.getExtension("publisher.my-extension"));
  });

  test("Command should be registered", function() {
    return vscode.commands.getCommands(true).then(function(commands) {
      var found = commands.indexOf("myExtension.helloWorld") !== -1;
      assert.ok(found, "Command myExtension.helloWorld not registered");
    });
  });

  test("Should show message on command execution", function() {
    return vscode.commands.executeCommand("myExtension.helloWorld");
  });
});

Run tests with the Extension Development Host:

# In .vscode/launch.json, use the "Extension Tests" config
# Press F5 to run tests in a new VS Code window

Publishing to the Marketplace

# Install the publishing tool
npm install -g @vscode/vsce

# Create a publisher (one-time)
# Visit https://marketplace.visualstudio.com/manage

# Login
vsce login your-publisher-name

# Package the extension
vsce package
# Creates my-extension-0.0.1.vsix

# Publish
vsce publish

# Publish with version bump
vsce publish minor

The .vscodeignore file controls what goes into the package:

.vscode/**
.vscode-test/**
test/**
.gitignore
.eslintrc.json
jsconfig.json

Complete Working Example: Word Counter Extension

// extension.js - A complete word/character counter extension

var vscode = require("vscode");

var statusBarItem;
var decorationType;

function activate(context) {
  // Create status bar item
  statusBarItem = vscode.window.createStatusBarItem(
    vscode.StatusBarAlignment.Right,
    200
  );
  statusBarItem.command = "wordCounter.showDetails";
  context.subscriptions.push(statusBarItem);

  // Decoration for long lines
  decorationType = vscode.window.createTextEditorDecorationType({
    backgroundColor: "rgba(255, 200, 0, 0.1)",
    isWholeLine: true,
    overviewRulerColor: "yellow",
    overviewRulerLane: vscode.OverviewRulerLane.Right
  });

  // Commands
  context.subscriptions.push(
    vscode.commands.registerCommand("wordCounter.showDetails", showDetails),
    vscode.commands.registerCommand("wordCounter.highlightLongLines", highlightLongLines)
  );

  // Events
  context.subscriptions.push(
    vscode.window.onDidChangeActiveTextEditor(updateStatusBar),
    vscode.workspace.onDidChangeTextDocument(function(e) {
      var editor = vscode.window.activeTextEditor;
      if (editor && e.document === editor.document) {
        updateStatusBar(editor);
      }
    }),
    vscode.window.onDidChangeTextEditorSelection(function() {
      updateStatusBar(vscode.window.activeTextEditor);
    })
  );

  // Initial update
  updateStatusBar(vscode.window.activeTextEditor);
}

function countStats(text) {
  var words = text.trim().split(/\s+/).filter(function(w) { return w.length > 0; });
  var chars = text.length;
  var charsNoSpaces = text.replace(/\s/g, "").length;
  var lines = text.split("\n").length;
  var paragraphs = text.split(/\n\s*\n/).filter(function(p) { return p.trim().length > 0; }).length;
  var readingTimeMin = Math.ceil(words.length / 200);

  return {
    words: words.length,
    chars: chars,
    charsNoSpaces: charsNoSpaces,
    lines: lines,
    paragraphs: paragraphs,
    readingTime: readingTimeMin
  };
}

function updateStatusBar(editor) {
  if (!editor) {
    statusBarItem.hide();
    return;
  }

  var text;
  var selection = editor.selection;

  if (!selection.isEmpty) {
    text = editor.document.getText(selection);
    var stats = countStats(text);
    statusBarItem.text = "$(pencil) " + stats.words + " words selected";
    statusBarItem.tooltip = stats.chars + " characters | " + stats.lines + " lines selected";
  } else {
    text = editor.document.getText();
    var fullStats = countStats(text);
    statusBarItem.text = "$(book) " + fullStats.words + " words";
    statusBarItem.tooltip = fullStats.chars + " characters | " +
      fullStats.lines + " lines | ~" + fullStats.readingTime + " min read";
  }

  statusBarItem.show();
}

function showDetails() {
  var editor = vscode.window.activeTextEditor;
  if (!editor) return;

  var text = editor.document.getText();
  var stats = countStats(text);

  var message = [
    "Words: " + stats.words,
    "Characters: " + stats.chars + " (" + stats.charsNoSpaces + " without spaces)",
    "Lines: " + stats.lines,
    "Paragraphs: " + stats.paragraphs,
    "Reading time: ~" + stats.readingTime + " min"
  ].join("\n");

  vscode.window.showInformationMessage(message, { modal: true });
}

function highlightLongLines() {
  var editor = vscode.window.activeTextEditor;
  if (!editor) return;

  var config = vscode.workspace.getConfiguration("wordCounter");
  var maxLength = config.get("maxLineLength", 120);

  var decorations = [];

  for (var i = 0; i < editor.document.lineCount; i++) {
    var line = editor.document.lineAt(i);
    if (line.text.length > maxLength) {
      decorations.push({
        range: line.range,
        hoverMessage: "Line is " + line.text.length + " chars (max: " + maxLength + ")"
      });
    }
  }

  editor.setDecorations(decorationType, decorations);

  vscode.window.showInformationMessage(
    "Found " + decorations.length + " lines exceeding " + maxLength + " characters"
  );
}

function deactivate() {
  if (decorationType) {
    decorationType.dispose();
  }
}

module.exports = { activate: activate, deactivate: deactivate };

Common Issues and Troubleshooting

Extension does not activate

The activation event in package.json does not match what you expect:

Fix: Add "onStartupFinished" to activationEvents for testing. Once working, narrow it to specific events. Check the Output panel (select your extension from the dropdown) for errors.

Command not found error

The command ID in registerCommand does not match package.json:

command 'myExtension.hello' not found

Fix: Ensure the command string is identical in both places. IDs are case-sensitive.

Webview content is blank

Content Security Policy blocks inline scripts:

Fix: Use a nonce for inline scripts, or load scripts from files using webview.asWebviewUri().

Extension slows down VS Code

Heavy computation on the main thread blocks the UI:

Fix: Move CPU-intensive work to a separate process using child_process, or use VS Code's built-in Worker support. Debounce event handlers that fire frequently like onDidChangeTextDocument.

Best Practices

  • Activate lazily. Only activate on the events you actually need. Broad activation like * slows VS Code startup for everyone.
  • Dispose everything. Push all disposables into context.subscriptions. Leaked watchers, decorations, and event listeners accumulate.
  • Use VS Code's built-in icons. The $(icon-name) syntax in status bar text gives you hundreds of Codicon icons without bundling images.
  • Respect user settings. Make behavior configurable through contributes.configuration. Never hardcode values users might want to change.
  • Test with the Extension Development Host. Press F5 to launch a new VS Code window with your extension loaded. Use the Debug Console for logs.
  • Keep the package small. Use .vscodeignore aggressively. Users should not download your test files, screenshots, or development configs.

References

Powered by Contentful