Build custom UI in the sidebar and panels, then teach the editor about your language — completions, hovers, diagnostics and, ultimately, a language server
Parts 1–3 covered activation, contributions and the vscode runtime API. Part 4 builds custom UI and language smarts on top of that foundation.
TreeDataProviderpostMessage protocolasWebviewUriVS Code offers several ways to render your own UI. Reach for the simplest one that does the job — native widgets are cheaper and more accessible than a webview.
| Surface | API | Renders | Reach For When… |
|---|---|---|---|
| Tree View | TreeDataProvider | Native list/tree widget | Hierarchical or list data in the sidebar |
| Webview Panel | createWebviewPanel | HTML/CSS/JS in an editor tab | Rich, bespoke UI (charts, forms, previews) |
| Webview View | WebviewViewProvider | HTML/CSS/JS in the sidebar | Custom UI docked in a view container |
| Custom Editor | CustomEditorProvider | Webview bound to a file type | Editing a custom file format visually |
| Status Bar | createStatusBarItem | Text/icon in the status bar | Glanceable status or a quick command entry |
Tree Views, Quick Picks and Status Bar items match the user's theme automatically, are keyboard-accessible, and need no HTML. Use them first.
A webview is a sandboxed iframe — powerful, but you own all HTML/CSS/JS, theming, accessibility and security. More flexible, more responsibility.
import * as vscode from 'vscode';
class NodeItem extends vscode.TreeItem {
constructor(label: string, public children: NodeItem[] = []) {
super(label, children.length
? vscode.TreeItemCollapsibleState.Collapsed
: vscode.TreeItemCollapsibleState.None);
this.iconPath = new vscode.ThemeIcon('symbol-field');
this.contextValue = 'node'; // keys menus
}
}
class MyProvider implements vscode.TreeDataProvider<NodeItem> {
private _onDidChange = new vscode.EventEmitter<NodeItem | void>();
readonly onDidChangeTreeData = this._onDidChange.event;
getTreeItem(el: NodeItem): vscode.TreeItem { return el; }
getChildren(el?: NodeItem): NodeItem[] {
return el ? el.children : this.roots;
}
refresh() { this._onDidChange.fire(); } // re-render
}
const provider = new MyProvider();
vscode.window.registerTreeDataProvider('myView', provider);
// or: const view = vscode.window.createTreeView('myView',
// { treeDataProvider: provider });
getChildren(el?) — children of a node, or roots when no arggetTreeItem(el) — the TreeItem to renderlabel, description, tooltipcollapsibleState — None / Collapsed / Expandedcommand — run on clickiconPath — a ThemeIcon or filecontextValue — keys menu visibilityFire an EventEmitter through onDidChangeTreeData. Fire with no arg to refresh the whole tree, or with an element to refresh one node.
Set treeItem.command to run a command when the row is clicked. Pass the item as an argument so the handler knows what was selected.
Contribute to view/item/context in package.json. Use the when clause viewItem == <contextValue> to show actions only on matching items.
Group "inline" renders an action as an icon button on hover. The command receives the tree item, just like a context-menu action.
view/title places buttons (e.g. Refresh) in the view header, keyed on view == myView.
{
"contributes": {
"commands": [
{ "command": "myView.refresh", "title": "Refresh",
"icon": "$(refresh)" },
{ "command": "myView.delete", "title": "Delete",
"icon": "$(trash)" }
],
"menus": {
"view/title": [
{ "command": "myView.refresh", "when": "view == myView",
"group": "navigation" }
],
"view/item/context": [
{ "command": "myView.delete",
"when": "view == myView && viewItem == node",
"group": "inline" }
]
}
}
}
vscode.commands.registerCommand('myView.delete',
(item: NodeItem) => {
model.remove(item);
provider.refresh(); // re-render the tree
});
A webview is a fully sandboxed iframe rendered inside an editor tab. You own its HTML, CSS and JS — it cannot import the vscode module or touch the editor directly.
import * as vscode from 'vscode';
export function openPreview(context: vscode.ExtensionContext) {
const panel = vscode.window.createWebviewPanel(
'myPreview', // viewType (internal id)
'My Preview', // tab title
vscode.ViewColumn.Beside, // where to show it
{
enableScripts: true, // allow <script> to run
retainContextWhenHidden: true,
}
);
panel.webview.html = getHtml(panel.webview);
panel.onDidDispose(() => {
// clean up timers, listeners, etc.
}, null, context.subscriptions);
}
function getHtml(webview: vscode.Webview): string {
return `<!DOCTYPE html><html><body>
<h1>Hello from a webview</h1>
</body></html>`;
}
viewType — stable id for serialisationtitle — shown on the tabshowOptions — a ViewColumnoptions — enableScripts, roots…Assign a full HTML document string to panel.webview.html. Reassigning it reloads the iframe from scratch.
No Node, no require, no vscode API. Scripts are off by default — set enableScripts: true to turn them on.
The extension host and the webview iframe talk only by passing serialisable messages. There is no shared memory — this channel is the entire bridge.
// receive from the webview
panel.webview.onDidReceiveMessage(msg => {
if (msg.type === 'save') save(msg.value);
}, undefined, context.subscriptions);
// send to the webview
panel.webview.postMessage({ type: 'update', count: 42 });
const vscode = acquireVsCodeApi();
// send to the extension
vscode.postMessage({ type: 'save', value: text });
// receive from the extension
window.addEventListener('message', event => {
const msg = event.data;
if (msg.type === 'update') render(msg.count);
});
function getHtml(webview: vscode.Webview, ext: vscode.Uri) {
// local files must be converted to webview URIs
const scriptUri = webview.asWebviewUri(
vscode.Uri.joinPath(ext, 'media', 'main.js'));
const nonce = getNonce(); // random per render
return `<!DOCTYPE html><html><head>
<meta http-equiv="Content-Security-Policy" content="
default-src 'none';
img-src ${webview.cspSource} https:;
style-src ${webview.cspSource};
script-src 'nonce-${nonce}';">
</head><body>
<div id="app"></div>
<script nonce="${nonce}" src="${scriptUri}"></script>
</body></html>`;
}
// allow the webview to load from these folders only
const panel = vscode.window.createWebviewPanel(
'view', 'Title', vscode.ViewColumn.One, {
enableScripts: true,
localResourceRoots: [vscode.Uri.joinPath(ext, 'media')],
retainContextWhenHidden: true,
});
default-src 'none'nonce${webview.cspSource} for local assetswebview.asWebviewUri(uri) rewrites a disk path into a URI the sandbox can fetch. List folders in localResourceRoots.
retainContextWhenHidden keeps the DOM alive (costs memory)getState() / setState() survive reloads cheaplyTreat every message from the webview as untrusted — validate before acting on it.
A Webview View renders custom HTML inside a sidebar view container instead of an editor tab — the same iframe sandbox and messaging, docked in the activity bar.
class MyViewProvider implements vscode.WebviewViewProvider {
constructor(private readonly ext: vscode.Uri) {}
resolveWebviewView(
view: vscode.WebviewView,
_ctx: vscode.WebviewViewResolveContext,
_token: vscode.CancellationToken,
) {
view.webview.options = {
enableScripts: true,
localResourceRoots: [this.ext],
};
view.webview.html = getHtml(view.webview, this.ext);
view.webview.onDidReceiveMessage(msg => {
/* handle messages from the view */
});
}
}
export function activate(context: vscode.ExtensionContext) {
const provider = new MyViewProvider(context.extensionUri);
context.subscriptions.push(
vscode.window.registerWebviewViewProvider(
'myWebviewView', provider));
}
"contributes": {
"views": {
"explorer": [
{ "type": "webview",
"id": "myWebviewView",
"name": "My Panel" }
]
}
}
"type": "webview" on the view contributionid must match registerWebviewViewProviderresolveWebviewView is called when the view first becomes visibleYou don't push features into the editor. You register a provider against a DocumentSelector; VS Code calls it back when the user types, hovers, or invokes an action.
CompletionItemProviderHoverProviderSignatureHelpProviderDefinitionProviderReferenceProviderDocumentSymbolProviderDiagnosticCollectionCodeActionProviderDocumentFormattingEditProvider, RenameProviderconst completion: vscode.CompletionItemProvider = {
provideCompletionItems(doc, position) {
const log = new vscode.CompletionItem(
'console.log', vscode.CompletionItemKind.Method);
log.insertText =
new vscode.SnippetString('console.log($1)$0');
log.documentation =
new vscode.MarkdownString('Log to the **console**');
const tag = new vscode.CompletionItem(
'TODO', vscode.CompletionItemKind.Keyword);
return [log, tag]; // CompletionItem[]
},
};
context.subscriptions.push(
vscode.languages.registerCompletionItemProvider(
{ language: 'javascript' }, completion,
'.', ':')); // trigger characters
const hover: vscode.HoverProvider = {
provideHover(doc, position) {
const word = doc.getText(
doc.getWordRangeAtPosition(position));
return new vscode.Hover(
new vscode.MarkdownString(`**${word}** — symbol`));
},
};
vscode.languages.registerHoverProvider(
{ language: 'javascript' }, hover);
label — text in the listkind — CompletionItemKind iconinsertText — string or SnippetStringdocumentation — MarkdownString$1, $2 are tab stops; $0 is the final cursor; ${1:label} gives a placeholder.
Pass characters after the provider (e.g. '.') so completions pop automatically as the user types them.
Return a Hover built from a MarkdownString; render docs, types or links on mouse-over.
const collection =
vscode.languages.createDiagnosticCollection('mylint');
function lint(doc: vscode.TextDocument) {
const diagnostics: vscode.Diagnostic[] = [];
for (let line = 0; line < doc.lineCount; line++) {
const text = doc.lineAt(line).text;
const idx = text.indexOf('FIXME');
if (idx === -1) continue;
const range = new vscode.Range(
line, idx, line, idx + 5);
const diag = new vscode.Diagnostic(
range, 'Unresolved FIXME comment',
vscode.DiagnosticSeverity.Warning);
diag.source = 'mylint';
diagnostics.push(diag);
}
collection.set(doc.uri, diagnostics);
}
// re-lint as the document changes
context.subscriptions.push(
vscode.workspace.onDidChangeTextDocument(
e => lint(e.document)),
vscode.workspace.onDidOpenTextDocument(lint),
collection);
range — a Range to underlinemessage — shown on hoverseverity — Error / Warning / Information / Hintsource, code — optional metadatacollection.set(uri, diags) publishes squiggles for a file; pass [] or delete(uri) to clear them.
Re-run linting from onDidChangeTextDocument (debounce for big files) and on open/close events.
Register a CodeActionProvider to offer a fix for a diagnostic — build a WorkspaceEdit and attach it to the action.
Write your language smarts once as a server speaking the protocol, then reuse it across VS Code, Neovim and other editors. The server runs in its own process.
vscode APITreeDataProvider, refresh via EventEmitterpostMessage & onDidReceiveMessageAdd a Tree View listing TODOs found in the workspace (scan files via workspace.findFiles), and open a webview detail panel showing the surrounding code when an item is clicked.
Up next: the test runner, vsce packaging, and shipping to the Marketplace.