Utiliser, créer et publier une extension VS Code en TypeScript : commandes, configuration, vues, webviews, diagnostics, tests, bundling et publication sur le Marketplace et Open VSX.
Une webview est une iframe que VS Code place dans un onglet ou une vue latérale. On y met ce qu'on veut : HTML, CSS, JavaScript, un graphique, une application React entière. C'est l'outil le plus puissant de l'API, et le plus facile à mal utiliser.
La documentation officielle le dit sans détour : une webview est lourde, elle ne respecte pas d'elle-même le thème ni l'accessibilité, et elle donne une interface qui ne ressemble pas au reste de l'éditeur. Avant d'en créer une, vérifiez qu'aucune API native ne suffit.
| Besoin | Solution native |
|---|---|
| Une liste ou un arbre | TreeView (chapitre 4) |
| Un choix parmi des options | showQuickPick |
| Une saisie | showInputBox |
| Une information au survol | HoverProvider (chapitre 6) |
| Un document en lecture seule | Un éditeur de texte virtuel (TextDocumentContentProvider) |
| Un graphique, un formulaire riche, un aperçu | Webview |
Pour TODO Radar, le tableau de bord des statistiques (répartition par marqueur, fichiers les plus chargés) justifie une webview.
import * as vscode from 'vscode'
import { FileTodos } from './workspace-scan'
export class StatsPanel {
private static current: StatsPanel | undefined
private readonly panel: vscode.WebviewPanel
static show(context: vscode.ExtensionContext, files: FileTodos[]) {
if (StatsPanel.current) {
StatsPanel.current.panel.reveal(vscode.ViewColumn.Beside)
StatsPanel.current.update(files)
return
}
StatsPanel.current = new StatsPanel(context, files)
}
private constructor(private readonly context: vscode.ExtensionContext, files: FileTodos[]) {
this.panel = vscode.window.createWebviewPanel(
'todoRadar.stats',
'TODO Radar — Statistiques',
vscode.ViewColumn.Beside,
{
enableScripts: true,
localResourceRoots: [vscode.Uri.joinPath(context.extensionUri, 'media')],
}
)
this.panel.webview.html = this.getHtml()
this.panel.onDidDispose(() => (StatsPanel.current = undefined), null, context.subscriptions)
this.update(files)
}
update(files: FileTodos[]) {
// envoyé à la webview, détaillé plus bas
}
private getHtml(): string {
return '' // détaillé plus bas
}
}Le motif static current garantit un seul panneau : relancer la commande ramène l'onglet existant au lieu d'en ouvrir un deuxième.
Les options de createWebviewPanel :
enableScripts autorise le JavaScript dans la page, désactivé par défaut ;localResourceRoots liste les seuls dossiers dont la webview peut charger des fichiers. Par défaut, c'est tout le workspace et le dossier de l'extension ; on restreint à media/ ;retainContextWhenHidden (non utilisé ici) garde la page en vie quand l'onglet passe en arrière-plan. Pratique, mais gourmand en mémoire. Préférez sauvegarder l'état, voir plus bas.Une webview ne peut pas charger ./media/stats.js directement : les chemins doivent être convertis en URI spéciales via asWebviewUri. Et toute webview qui exécute du script doit déclarer une Content Security Policy stricte.
private getHtml(): string {
const webview = this.panel.webview
const media = (file: string) =>
webview.asWebviewUri(vscode.Uri.joinPath(this.context.extensionUri, 'media', file))
const nonce = crypto.randomUUID().replace(/-/g, '')
return /* html */ `<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Security-Policy"
content="default-src 'none';
style-src ${webview.cspSource};
img-src ${webview.cspSource} https:;
script-src 'nonce-${nonce}';">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="${media('stats.css')}" rel="stylesheet">
<title>Statistiques</title>
</head>
<body>
<h1>Marqueurs du projet</h1>
<div id="summary"></div>
<ol id="files"></ol>
<script nonce="${nonce}" src="${media('stats.js')}"></script>
</body>
</html>`
}La politique dit : rien n'est autorisé par défaut, les styles et images viennent uniquement de l'extension, et seuls les scripts portant le nonce généré s'exécutent. Un texte de TODO contenant <script> injecté dans la page ne s'exécutera donc pas. C'est la même défense que sur un site web, et le même raisonnement que dans l'article sur la cybersécurité pour les développeurs : ne jamais faire confiance au contenu affiché.
crypto.randomUUID() est disponible globalement dans Node.js 19 et plus, que VS Code embarque depuis longtemps.
VS Code injecte dans chaque webview des variables CSS reprenant les couleurs du thème actif, et une classe sur <body> (vscode-light, vscode-dark, vscode-high-contrast). On s'en sert au lieu de couleurs codées en dur :
body {
font-family: var(--vscode-font-family);
font-size: var(--vscode-font-size);
color: var(--vscode-foreground);
background: var(--vscode-editor-background);
padding: 0 1rem;
}
.bar {
height: 0.5rem;
background: var(--vscode-charts-yellow);
border-radius: 2px;
}
a {
color: var(--vscode-textLink-foreground);
}
li {
border-bottom: 1px solid var(--vscode-panel-border);
padding: 0.5rem 0;
}Le nom d'une variable reprend celui de la couleur du thème : editor.background devient --vscode-editor-background. Les outils de développement de la webview (commande Developer: Open Webview Developer Tools) montrent la liste complète sur l'élément html.
La webview est isolée : elle n'a pas accès à l'API vscode. Les deux côtés échangent des messages JSON.
Côté extension, on envoie les données et on écoute les demandes :
update(files: FileTodos[]) {
const byTag: Record<string, number> = {}
for (const f of files) for (const m of f.matches) byTag[m.tag] = (byTag[m.tag] ?? 0) + 1
const topFiles = files
.map((f) => ({ path: vscode.workspace.asRelativePath(f.uri), uri: f.uri.toString(), count: f.matches.length }))
.sort((a, b) => b.count - a.count)
.slice(0, 10)
this.panel.webview.postMessage({ type: 'stats', byTag, topFiles })
}Et dans le constructeur :
this.panel.webview.onDidReceiveMessage(
async (message: { type: string; uri?: string }) => {
if (message.type === 'open' && message.uri) {
await vscode.window.showTextDocument(vscode.Uri.parse(message.uri), {
viewColumn: vscode.ViewColumn.One,
})
}
},
null,
context.subscriptions
)Côté webview, acquireVsCodeApi() renvoie l'objet qui envoie les messages. On ne peut l'appeler qu'une fois par page.
const vscode = acquireVsCodeApi()
const summary = document.getElementById('summary')
const list = document.getElementById('files')
function render({ byTag, topFiles }) {
summary.textContent = Object.entries(byTag)
.map(([tag, count]) => `${tag} : ${count}`)
.join(' · ')
const max = Math.max(1, ...topFiles.map((f) => f.count))
list.replaceChildren(
...topFiles.map((file) => {
const li = document.createElement('li')
const link = document.createElement('a')
link.href = '#'
link.textContent = `${file.path} (${file.count})`
link.addEventListener('click', (e) => {
e.preventDefault()
vscode.postMessage({ type: 'open', uri: file.uri })
})
const bar = document.createElement('div')
bar.className = 'bar'
bar.style.width = `${(file.count / max) * 100}%`
li.append(link, bar)
return li
})
)
}
window.addEventListener('message', (event) => {
if (event.data.type === 'stats') {
render(event.data)
vscode.setState(event.data)
}
})
// Restaurer l'affichage si l'onglet a été masqué puis réaffiché
const previous = vscode.getState()
if (previous) render(previous)On construit le DOM avec textContent plutôt qu'innerHTML : le texte des fichiers et des TODO ne sera jamais interprété comme du HTML. La CSP est la deuxième ligne de défense, pas la première.
setState et getState remplacent retainContextWhenHidden à moindre coût : quand l'onglet revient au premier plan, la page est recréée mais retrouve son dernier état instantanément.
context.subscriptions.push(
vscode.commands.registerCommand('todo-radar.showStats', async () => {
const files = await scanWorkspace()
StatsPanel.show(context, files)
})
){
"command": "todo-radar.showStats",
"title": "Afficher les statistiques",
"category": "TODO Radar",
"icon": "$(graph)"
}On ajoute aussi cette commande dans view/title, à côté du bouton de rafraîchissement.
Pour une interface riche, on écrit la webview avec React, Vue ou Svelte, bundlée séparément par esbuild ou Vite dans media/. Le principe reste le même : un fichier JS chargé avec un nonce, et des messages pour parler à l'extension. Si vous hésitez entre les deux premiers, la comparaison React vs Vue aide à trancher.
Pour les composants d'interface, la bibliothèque @vscode/webview-ui-toolkit a été archivée par Microsoft en 2025. Les projets actuels s'appuient sur les variables CSS ci-dessus, ou sur @vscode-elements/elements, une bibliothèque de Web Components maintenue par la communauté qui reproduit les contrôles natifs.
Une webview peut aussi vivre dans la barre latérale, sous la vue des marqueurs : on déclare la vue avec "type": "webview" dans contributes.views et on implémente un WebviewViewProvider au lieu de créer un panneau.
L'extension sait afficher. Le chapitre suivant la fait parler le langage de l'éditeur : diagnostics soulignés en jaune, corrections rapides, complétion et survol.