Visual Studio Code launched in 2015 and quickly became the most popular code editor in the world. The Stack Overflow Developer Survey consistently shows 70%+ adoption. The secret is its extension model.
Unlike monolithic IDEs, VSCode ships a lean core and delegates language support, debugging, themes, and tooling to extensions. This means the community — not Microsoft — drives most of the editor's capability.
VSCode is built on Electron (Chromium + Node.js). Extensions run in a separate Extension Host process so a misbehaving extension cannot freeze the editor UI.
The UI you see. Monaco editor, tree views, status bar. Communicates with Extension Host over IPC (JSON-RPC).
A Node.js process that loads and runs extensions. Extensions share this process — heavy work should be offloaded to a Language Server or worker.
A JSON-RPC protocol between the Extension Host and a separate language server process. Enables completions, diagnostics, hover, go-to-definition, and more.
my-extension/
├── .vscode/
│ └── launch.json # debug config
├── src/
│ └── extension.ts # entry point
├── package.json # manifest (critical!)
├── tsconfig.json # TypeScript config
├── README.md # Marketplace listing
├── CHANGELOG.md # version history
└── icon.png # 128x128 Marketplace icon
Extensions are lazy-loaded. They only activate when a matching event fires:
onCommand:myExt.run — command executedonLanguage:python — file of language openedworkspaceContains:**/Cargo.toml — file existsonStartupFinished — after startup (use sparingly)* — always active (strongly discouraged){
"name": "my-extension",
"displayName": "My Extension",
"version": "1.0.0",
"engines": { "vscode": "^1.85.0" },
"activationEvents": [],
"main": "./out/extension.js",
"contributes": {
"commands": [{
"command": "myExt.helloWorld",
"title": "Hello World"
}],
"keybindings": [{
"command": "myExt.helloWorld",
"key": "ctrl+shift+h",
"when": "editorTextFocus"
}],
"configuration": {
"title": "My Extension",
"properties": {
"myExt.greeting": {
"type": "string",
"default": "Hello"
}
}
}
}
}
# Install the generator
npm install -g yo generator-code
# Scaffold a new extension
yo code
# Choose:
# ▸ New Extension (TypeScript)
# Name: hello-world
# Identifier: hello-world
# Enable strict mode: Yes
F5 — opens Extension Development HostCtrl+Shift+P → "Hello World"Edit code → Ctrl+Shift+F5 to reload the Development Host. Breakpoints work in the main VSCode instance. Console output appears in the Debug Console.
import * as vscode from 'vscode';
// Called when the extension is activated
export function activate(context: vscode.ExtensionContext) {
// Register a command
const disposable = vscode.commands.registerCommand(
'hello-world.helloWorld',
() => {
// Read from configuration
const config = vscode.workspace.getConfiguration('myExt');
const greeting = config.get<string>('greeting', 'Hello');
// Show an information message
vscode.window.showInformationMessage(
`${greeting} from Hello World!`
);
}
);
// Push to subscriptions for cleanup
context.subscriptions.push(disposable);
}
// Called when the extension is deactivated
export function deactivate() {}
Commands are the primary interaction model in VSCode. Every action — from opening a file to formatting code — is a command. Extensions register commands and bind them to menus, keybindings, or other triggers.
// Register a text editor command
const cmd = vscode.commands.registerTextEditorCommand(
'myExt.insertDate',
(editor, edit) => {
const date = new Date().toISOString().split('T')[0];
edit.insert(editor.selection.active, date);
}
);
// Execute another extension's command
await vscode.commands.executeCommand(
'editor.action.formatDocument'
);
Control visibility and enablement with context conditions:
"when": "editorLangId == typescript && !editorReadonly"
"when": "resourceScheme == file"
"when": "view == myCustomView"
"contributes": {
"menus": {
"editor/context": [{
"command": "myExt.insertDate",
"when": "editorTextFocus",
"group": "1_modification"
}],
"explorer/context": [{
"command": "myExt.processFile",
"when": "resourceExtname == .csv"
}],
"editor/title": [{
"command": "myExt.togglePreview",
"group": "navigation"
}],
"commandPalette": [{
"command": "myExt.insertDate",
"when": "editorIsOpen"
}]
}
}
navigation (top), 1_modification, 2_workspace, z_commands (bottom). Items sort alphabetically within groups.
VSCode provides two approaches: programmatic language features (direct API) for simple cases and the Language Server Protocol for full-featured language support.
// Completion provider
vscode.languages.registerCompletionItemProvider(
'markdown',
{
provideCompletionItems(doc, pos) {
const items = [
new vscode.CompletionItem('TODO',
vscode.CompletionItemKind.Keyword),
new vscode.CompletionItem('FIXME',
vscode.CompletionItemKind.Keyword),
];
return items;
}
},
'@' // trigger character
);
// Hover provider
vscode.languages.registerHoverProvider('json', {
provideHover(doc, pos) {
const range = doc.getWordRangeAtPosition(pos);
const word = doc.getText(range);
return new vscode.Hover(`Key: **${word}**`);
}
});
| Provider | Feature |
|---|---|
| CompletionItem | IntelliSense suggestions |
| Hover | Tooltip on hover |
| Definition | Go to definition |
| Reference | Find all references |
| DocumentSymbol | Outline & breadcrumbs |
| CodeAction | Quick fixes & refactors |
| CodeLens | Inline actionable info |
| Diagnostic | Errors & warnings |
| Formatter | Document / range formatting |
| SignatureHelp | Parameter hints |
const diag = vscode.languages.createDiagnosticCollection('myLint');
diag.set(doc.uri, [
new vscode.Diagnostic(range, 'Unused variable',
vscode.DiagnosticSeverity.Warning)
]);
The Language Server Protocol is a JSON-RPC protocol between an editor and a language-specific server. Invented by Microsoft for VSCode, it's now used by Vim, Emacs, Sublime, and others.
// Client side (extension.ts)
import { LanguageClient, TransportKind }
from 'vscode-languageclient/node';
const serverModule = context.asAbsolutePath(
'server/out/server.js'
);
const client = new LanguageClient(
'myLangServer', 'My Language Server',
{ module: serverModule, transport: TransportKind.ipc },
{ documentSelector: [{ scheme: 'file', language: 'myLang' }] }
);
client.start();
typescript-language-server, rust-analyzer, pylsp, gopls, clangd, lua-language-server
TreeViews let you add custom panels to the sidebar, panel area, or Activity Bar. They display hierarchical data with icons, descriptions, and context menus.
"contributes": {
"viewsContainers": {
"activitybar": [{
"id": "myExtExplorer",
"title": "My Extension",
"icon": "resources/icon.svg"
}]
},
"views": {
"myExtExplorer": [{
"id": "myExt.projectsView",
"name": "Projects"
}, {
"id": "myExt.tasksView",
"name": "Tasks"
}]
}
}
class ProjectProvider
implements vscode.TreeDataProvider<ProjectItem> {
private _onDidChange = new vscode.EventEmitter<
ProjectItem | undefined
>();
readonly onDidChangeTreeData = this._onDidChange.event;
getTreeItem(el: ProjectItem): vscode.TreeItem {
return el;
}
async getChildren(el?: ProjectItem) {
if (!el) {
return this.getProjects(); // root items
}
return el.getFiles(); // child items
}
refresh(): void {
this._onDidChange.fire(undefined);
}
}
// Register the provider
vscode.window.registerTreeDataProvider(
'myExt.projectsView',
new ProjectProvider()
);
Webviews are sandboxed iframes that render custom HTML/CSS/JS inside VSCode. Use them for rich UIs that go beyond the built-in component model — dashboards, visual editors, forms, or rendered previews.
// Create a Webview panel
const panel = vscode.window.createWebviewPanel(
'myPreview', // internal ID
'My Preview', // title
vscode.ViewColumn.Two, // editor column
{
enableScripts: true, // allow JS
retainContextWhenHidden: true, // keep state
localResourceRoots: [ // restrict access
vscode.Uri.joinPath(context.extensionUri, 'media')
]
}
);
// Set HTML content
panel.webview.html = getHtmlContent(panel.webview);
// Receive messages from webview
panel.webview.onDidReceiveMessage(msg => {
if (msg.command === 'save') {
saveData(msg.data);
}
});
<!-- Inside the webview HTML -->
<script>
const vscode = acquireVsCodeApi();
// Send message to extension
vscode.postMessage({
command: 'save',
data: { name: 'test' }
});
// Receive messages from extension
window.addEventListener('message', event => {
const msg = event.data;
if (msg.command === 'update') {
document.getElementById('content')
.textContent = msg.text;
}
});
// Persist state across visibility changes
vscode.setState({ scrollPos: 42 });
const state = vscode.getState();
</script>
Webviews are sandboxed. Use Content-Security-Policy headers, nonce attributes on scripts, and localResourceRoots to restrict file access. Never inject user content as raw HTML.
// Find files
const files = await vscode.workspace.findFiles(
'**/*.test.ts', // include
'**/node_modules/**' // exclude
);
// Read & write files
const uri = vscode.Uri.file('/path/to/file.txt');
const data = await vscode.workspace.fs.readFile(uri);
await vscode.workspace.fs.writeFile(uri, Buffer.from('new'));
// Watch for changes
const watcher = vscode.workspace.createFileSystemWatcher(
'**/*.json'
);
watcher.onDidChange(uri => console.log('Changed:', uri));
watcher.onDidCreate(uri => console.log('Created:', uri));
watcher.onDidDelete(uri => console.log('Deleted:', uri));
// Apply a batch of edits
const wsEdit = new vscode.WorkspaceEdit();
wsEdit.replace(uri, range, 'replacement text');
wsEdit.createFile(newUri, { ignoreIfExists: true });
await vscode.workspace.applyEdit(wsEdit);
// Read configuration
const config = vscode.workspace.getConfiguration('myExt');
const value = config.get<string>('greeting', 'default');
// Write configuration
await config.update(
'greeting', 'Bonjour',
vscode.ConfigurationTarget.Workspace
);
// Watch for config changes
vscode.workspace.onDidChangeConfiguration(e => {
if (e.affectsConfiguration('myExt.greeting')) {
// reload
}
});
Use context.globalState and context.workspaceState for persistent key-value storage. Use context.secrets for sensitive data (tokens, passwords) — stored in the OS keychain.
VSCode has first-class support for debugging extensions. Press F5 to launch an Extension Development Host — a second VSCode window with your extension loaded.
{
"version": "0.2.0",
"configurations": [
{
"name": "Run Extension",
"type": "extensionHost",
"request": "launch",
"args": ["--extensionDevelopmentPath=${workspaceFolder}"],
"outFiles": ["${workspaceFolder}/out/**/*.js"],
"preLaunchTask": "npm: watch"
},
{
"name": "Extension Tests",
"type": "extensionHost",
"request": "launch",
"args": [
"--extensionDevelopmentPath=${workspaceFolder}",
"--extensionTestsPath=${workspaceFolder}/out/test"
]
}
]
}
// Create a dedicated output channel
const log = vscode.window.createOutputChannel('My Extension');
log.appendLine('Extension activated');
log.show(); // focus the channel
// Use console.log too — appears in Debug Console
console.log('Debug info:', someObject);
engines.vscode version in package.jsonExtension tests run inside a real VSCode instance via @vscode/test-electron. This gives tests full access to the VSCode API, real file systems, and installed extensions.
// src/test/runTest.ts
import { runTests } from '@vscode/test-electron';
import path from 'path';
async function main() {
await runTests({
extensionDevelopmentPath: path.resolve(__dirname, '../../'),
extensionTestsPath: path.resolve(__dirname, './suite/index'),
launchArgs: [
'--disable-extensions', // isolate your extension
path.resolve(__dirname, '../../test-fixtures')
]
});
}
main();
// src/test/suite/extension.test.ts
import * as assert from 'assert';
import * as vscode from 'vscode';
suite('Extension Tests', () => {
test('Command is registered', async () => {
const cmds = await vscode.commands.getCommands(true);
assert.ok(cmds.includes('myExt.helloWorld'));
});
test('Inserts date at cursor', async () => {
const doc = await vscode.workspace.openTextDocument({
content: '', language: 'plaintext'
});
const editor = await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand('myExt.insertDate');
const text = doc.getText();
assert.match(text, /^\d{4}-\d{2}-\d{2}$/);
});
});
Pure logic (parsers, formatters, utils) can be tested with Jest or Mocha directly — no need for the Extension Host. Only test VSCode API interactions with @vscode/test-electron.
Create custom notebook types (like Jupyter) with your own kernel. Register a NotebookSerializer to load/save and a NotebookController to execute cells.
const controller = vscode.notebooks
.createNotebookController(
'myKernel', 'my-notebook', 'My Kernel'
);
controller.executeHandler = async (cells) => {
for (const cell of cells) {
const exec = controller.createNotebookCellExecution(cell);
exec.start(Date.now());
// Run the cell content
const result = evaluate(cell.document.getText());
exec.replaceOutput([
new vscode.NotebookCellOutput([
vscode.NotebookCellOutputItem.text(result)
])
]);
exec.end(true);
}
};
Create, write to, and manage integrated terminals programmatically.
// Create a terminal
const term = vscode.window.createTerminal({
name: 'My Build',
cwd: '/path/to/project',
env: { NODE_ENV: 'test' }
});
term.show();
term.sendText('npm test');
// Terminal link provider
vscode.window.registerTerminalLinkProvider({
provideTerminalLinks(ctx) {
// Detect patterns in terminal output
const match = ctx.line.match(
/error in (.+):(\d+)/
);
if (match) {
return [{
startIndex: match.index,
length: match[0].length,
file: match[1], line: match[2]
}];
}
},
handleTerminalLink(link) {
// Open the file at the error line
}
});
Extensions can provide their own SCM (not just Git). Register changes, groups, and quick-diff decorations.
const scm = vscode.scm.createSourceControl(
'myScm', 'My SCM'
);
const changes = scm.createResourceGroup(
'changes', 'Changes'
);
changes.resourceStates = [{
resourceUri: fileUri,
decorations: {
strikeThrough: false,
tooltip: 'Modified'
}
}];
scm.quickDiffProvider = {
provideOriginalResource(uri) {
// Return URI of the original file
return toOriginalUri(uri);
}
};
vsce (Visual Studio Code Extensions) is the CLI tool for packaging and publishing. It bundles your extension into a .vsix file — a zip containing your compiled code, manifest, README, and assets.
# Install vsce
npm install -g @vscode/vsce
# Package into a .vsix
vsce package
# Creates: my-extension-1.0.0.vsix
# Install locally for testing
code --install-extension my-extension-1.0.0.vsix
# Check what will be included
vsce ls
Like .gitignore but for packaging. Exclude dev files to keep the vsix small:
.vscode/**
src/**
node_modules/**
!node_modules/prod-dependency/**
**/*.test.ts
tsconfig.json
.eslintrc.json
Bundle your extension into a single file for faster activation and smaller size:
// esbuild.mjs
import { build } from 'esbuild';
await build({
entryPoints: ['src/extension.ts'],
bundle: true,
outfile: 'out/extension.js',
external: ['vscode'], // provided by VSCode
format: 'cjs',
platform: 'node',
minify: true,
sourcemap: true,
});
engines.vscode is the minimum supported version# 1. Create a publisher at:
# https://marketplace.visualstudio.com/manage
# 2. Create a Personal Access Token (PAT)
# Azure DevOps → User Settings → PATs
# Scope: Marketplace (Manage)
# 3. Login with vsce
vsce login <publisher-name>
# Paste your PAT when prompted
# Publish current version
vsce publish
# Bump version and publish
vsce publish minor # 1.0.0 → 1.1.0
vsce publish patch # 1.1.0 → 1.1.1
# Pre-release versions
vsce publish --pre-release
vsce unpublish <publisher>.<extension> removes it entirely. Use with caution — users who depend on it will lose access.
{
"publisher": "myPublisher",
"displayName": "My Extension",
"description": "One-line summary",
"icon": "icon.png",
"galleryBanner": {
"color": "#0a0a0f",
"theme": "dark"
},
"categories": [
"Programming Languages",
"Formatters",
"Linters"
],
"keywords": ["python", "linting", "formatter"],
"repository": {
"type": "git",
"url": "https://github.com/me/my-ext"
},
"badges": [{
"url": "https://img.shields.io/...",
"href": "https://...",
"description": "Build Status"
}]
}
displayNamecategories and keywordsname: CI
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npm run lint
- run: npm run compile
- run: npm test
env:
DISPLAY: ':99.0'
- run: npx @vscode/vsce package
- uses: actions/upload-artifact@v4
with:
name: vsix
path: '*.vsix'
publish:
needs: build
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npx @vscode/vsce publish
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
Extension tests launch a real VSCode window and need a display server. On Linux CI, use xvfb (X Virtual Frame Buffer):
- run: |
sudo apt-get install -y xvfb
xvfb-run -a npm test
v1.2.3 tag to trigger publishTest on ubuntu, macos, and windows runners. Path separators, line endings, and shell behaviour differ. Use a matrix strategy:
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
*setTimeout or setImmediateHelp > Start Extension Bisect) to find slow extensionsCancellationToken to abort stale requestsDeveloper: Show Running ExtensionsDeveloper: Startup Performance shows exactly when your extension activated, how long it took, and what triggered it.
context.secrets for tokens (OS keychain)package.json is the manifest — declares everythingThank you! — Built with Reveal.js · Single self-contained HTML file