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.
VS Code ne laisse pas une extension dessiner où elle veut. Il fournit des emplacements précis, chacun avec son API : barre d'état, notifications, listes de sélection, vues arborescentes, décorations dans l'éditeur. Cette contrainte garantit que toutes les extensions se ressemblent et respectent le thème de l'utilisateur.
Les vues ont besoin des marqueurs de tout le projet, pas seulement du fichier ouvert. On ajoute un scan du workspace :
import * as vscode from 'vscode'
import { scanText, TodoMatch } from './scanner'
import { getConfig } from './config'
export interface FileTodos {
uri: vscode.Uri
matches: TodoMatch[]
}
const decoder = new TextDecoder('utf-8')
export async function scanWorkspace(token?: vscode.CancellationToken): Promise<FileTodos[]> {
const { tags, exclude } = getConfig()
const uris = await vscode.workspace.findFiles(
'**/*.{ts,tsx,js,jsx,vue,py,go,rs,java,cs,php,css,scss,html,md}',
exclude,
5000,
token
)
const results: FileTodos[] = []
for (const uri of uris) {
if (token?.isCancellationRequested) break
const bytes = await vscode.workspace.fs.readFile(uri)
const matches = scanText(decoder.decode(bytes), tags)
if (matches.length > 0) results.push({ uri, matches })
}
return results
}Deux choix méritent explication. vscode.workspace.fs plutôt que le module fs de Node : il fonctionne aussi sur des fichiers distants (SSH, conteneurs, WSL) et dans vscode.dev. Le CancellationToken permet d'interrompre un scan long quand l'utilisateur annule.
Trois niveaux de notification, et des boutons qui renvoient le choix de l'utilisateur :
const choice = await vscode.window.showWarningMessage(
'42 TODO sans ticket dans le projet.',
'Afficher',
'Ignorer'
)
if (choice === 'Afficher') {
await vscode.commands.executeCommand('todoRadar.list.focus')
}Une notification ne doit pas interrompre pour rien. Une extension qui affiche un message à chaque ouverture de fichier est désinstallée dans la journée.
Pour une opération longue, withProgress affiche une barre et un bouton d'annulation :
const results = await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: 'TODO Radar : scan du projet',
cancellable: true,
},
(progress, token) => {
progress.report({ message: 'Recherche des fichiers…' })
return scanWorkspace(token)
}
)ProgressLocation.Window affiche une simple icône animée dans la barre d'état, plus discrète.
showQuickPick est la liste filtrable qu'on connaît par Ctrl+P. Parfait pour sauter à un TODO au clavier :
vscode.commands.registerCommand('todo-radar.goTo', async () => {
const results = await scanWorkspace()
const items = results.flatMap(({ uri, matches }) =>
matches.map((m) => ({
label: `$(check) ${m.tag}`,
description: m.text,
detail: `${vscode.workspace.asRelativePath(uri)}:${m.line + 1}`,
uri,
match: m,
}))
)
const picked = await vscode.window.showQuickPick(items, {
placeHolder: 'Aller à un marqueur',
matchOnDescription: true,
matchOnDetail: true,
})
if (!picked) return
const position = new vscode.Position(picked.match.line, picked.match.column)
await vscode.window.showTextDocument(picked.uri, {
selection: new vscode.Range(position, position),
})
})$(check) est une codicon, l'une des centaines d'icônes intégrées à VS Code. La syntaxe $(nom) fonctionne dans les libellés, la barre d'état et les titres de commandes. La liste complète est sur le site de documentation de l'API, page Product Icon Reference.
Pour une saisie libre, showInputBox accepte une fonction de validation :
const tag = await vscode.window.showInputBox({
prompt: 'Nouveau marqueur à suivre',
placeHolder: 'NOTE',
validateInput: (value) => (/^[A-Z]+$/.test(value) ? null : 'Majuscules uniquement'),
})Le compteur du fichier courant va en bas à gauche :
import * as vscode from 'vscode'
import { scanText } from './scanner'
import { getConfig } from './config'
export function createStatusBar(context: vscode.ExtensionContext) {
const item = vscode.window.createStatusBarItem(
'todoRadar.count',
vscode.StatusBarAlignment.Left,
100
)
item.name = 'TODO Radar'
item.command = 'todo-radar.goTo'
context.subscriptions.push(item)
const update = () => {
const editor = vscode.window.activeTextEditor
if (!editor) {
item.hide()
return
}
const count = scanText(editor.document.getText(), getConfig().tags).length
item.text = `$(checklist) ${count}`
item.tooltip = `${count} marqueur(s) dans ce fichier — cliquer pour naviguer`
item.backgroundColor =
count > 10 ? new vscode.ThemeColor('statusBarItem.warningBackground') : undefined
item.show()
}
context.subscriptions.push(
vscode.window.onDidChangeActiveTextEditor(update),
vscode.workspace.onDidChangeTextDocument((e) => {
if (e.document === vscode.window.activeTextEditor?.document) update()
})
)
update()
}ThemeColor référence une couleur du thème actif au lieu d'un code hexadécimal. L'extension reste lisible en thème clair, sombre et contraste élevé. Pour la barre d'état, seules statusBarItem.warningBackground et statusBarItem.errorBackground sont acceptées comme fond.
onDidChangeTextDocument se déclenche à chaque frappe. Sur un gros fichier, rescanner à chaque caractère se sent. On ajoutera un délai (debounce) au chapitre 6, quand le même problème touchera les diagnostics.
La pièce principale de TODO Radar : une icône dans la barre d'activité, une vue qui liste les fichiers et leurs marqueurs.
D'abord la déclaration :
"viewsContainers": {
"activitybar": [
{
"id": "todo-radar",
"title": "TODO Radar",
"icon": "media/radar.svg"
}
]
},
"views": {
"todo-radar": [
{
"id": "todoRadar.list",
"name": "Marqueurs"
}
]
},
"viewsWelcome": [
{
"view": "todoRadar.list",
"contents": "Aucun marqueur trouvé pour l'instant.\n[Scanner le projet](command:todo-radar.refresh)"
}
]L'icône de la barre d'activité est un SVG monochrome de 24×24, sans couleur imposée : VS Code la teinte selon le thème. viewsWelcome s'affiche quand la vue est vide ; la syntaxe [texte](command:id) crée un bouton.
Ensuite le fournisseur de données. Un TreeDataProvider répond à deux questions : quels sont les enfants d'un élément, et comment afficher un élément.
import * as vscode from 'vscode'
import { FileTodos, scanWorkspace } from './workspace-scan'
import { TodoMatch } from './scanner'
type Node =
| { kind: 'file'; file: FileTodos }
| { kind: 'match'; uri: vscode.Uri; match: TodoMatch }
export class TodoTreeProvider implements vscode.TreeDataProvider<Node> {
private files: FileTodos[] = []
private readonly changed = new vscode.EventEmitter<Node | undefined>()
readonly onDidChangeTreeData = this.changed.event
async refresh() {
this.files = await scanWorkspace()
this.changed.fire(undefined)
}
get total() {
return this.files.reduce((sum, f) => sum + f.matches.length, 0)
}
getChildren(node?: Node): Node[] {
if (!node) return this.files.map((file) => ({ kind: 'file', file }))
if (node.kind === 'file') {
return node.file.matches.map((match) => ({ kind: 'match', uri: node.file.uri, match }))
}
return []
}
getTreeItem(node: Node): vscode.TreeItem {
if (node.kind === 'file') {
const item = new vscode.TreeItem(
node.file.uri,
vscode.TreeItemCollapsibleState.Expanded
)
item.description = `${node.file.matches.length}`
return item
}
const { match, uri } = node
const item = new vscode.TreeItem(match.text || match.tag)
item.description = `${match.tag} · ligne ${match.line + 1}`
item.iconPath = new vscode.ThemeIcon(match.tag === 'FIXME' ? 'bug' : 'check')
item.contextValue = 'todoMatch'
item.command = {
command: 'vscode.open',
title: 'Ouvrir',
arguments: [uri, { selection: new vscode.Range(match.line, match.column, match.line, match.column) }],
}
return item
}
}Quelques détails qui font la différence :
Uri au constructeur de TreeItem lui donne automatiquement le nom du fichier et l'icône du thème d'icônes de l'utilisateur ;description s'affiche en gris à droite du libellé ;contextValue sert à cibler cet élément dans un menu (viewItem == todoMatch) ;command rend l'élément cliquable ; vscode.open est une commande intégrée.L'enregistrement dans activate :
const tree = new TodoTreeProvider()
const view = vscode.window.createTreeView('todoRadar.list', {
treeDataProvider: tree,
showCollapseAll: true,
})
context.subscriptions.push(view)
const refresh = async () => {
await tree.refresh()
view.badge = { value: tree.total, tooltip: `${tree.total} marqueurs` }
await vscode.commands.executeCommand('setContext', 'todoRadar.hasTodos', tree.total > 0)
}
context.subscriptions.push(
vscode.commands.registerCommand('todo-radar.refresh', refresh),
vscode.workspace.onDidSaveTextDocument(refresh)
)
refresh()view.badge affiche le total en pastille sur l'icône de la barre d'activité. Un bouton de rafraîchissement dans la barre de titre de la vue complète l'ensemble :
"commands": [
{
"command": "todo-radar.refresh",
"title": "Rafraîchir",
"category": "TODO Radar",
"icon": "$(refresh)"
}
],
"menus": {
"view/title": [
{
"command": "todo-radar.refresh",
"when": "view == todoRadar.list",
"group": "navigation"
}
]
}Dernière brique visuelle : colorer les marqueurs dans le texte. Une décoration se crée une fois, puis s'applique à des plages :
import * as vscode from 'vscode'
import { scanText } from './scanner'
import { getConfig } from './config'
const todoDecoration = vscode.window.createTextEditorDecorationType({
backgroundColor: new vscode.ThemeColor('editor.findMatchHighlightBackground'),
overviewRulerColor: new vscode.ThemeColor('editorOverviewRuler.warningForeground'),
overviewRulerLane: vscode.OverviewRulerLane.Right,
fontWeight: 'bold',
})
export function decorate(editor: vscode.TextEditor) {
const ranges = scanText(editor.document.getText(), getConfig().tags).map(
(m) => new vscode.Range(m.line, m.column, m.line, m.column + m.tag.length)
)
editor.setDecorations(todoDecoration, ranges)
}overviewRulerColor ajoute un repère dans la barre de défilement, ce qui permet de repérer les marqueurs d'un fichier de 2 000 lignes d'un coup d'œil. On appelle decorate dans les mêmes événements que la barre d'état.
TODO Radar a désormais une interface native. Quand une extension a besoin de plus qu'une liste ou un compteur, il reste la webview, un panneau HTML libre : c'est le sujet du chapitre suivant.