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
varstyle) - 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
.vscodeignoreaggressively. Users should not download your test files, screenshots, or development configs.