Anatomy of an extension
The manifest's contribution points, activation, and what ExtensionContext carries
An extension is a manifest plus an activate function, built with defineExtension from
codelet/extensions. This page covers what goes in each half, when activate runs, and what
it's handed.
import { defineExtension } from "codelet/extensions";
export const todo = defineExtension({
manifest: {
name: "todo",
contributes: {
commands: [{ command: "todo.add", title: "Add To Do" }],
},
},
activate(context, vscode) {
context.subscriptions.push(
vscode.commands.registerCommand("todo.add", () => {
/* … */
}),
);
},
});defineExtension does nothing at runtime beyond returning what you pass it. It exists so
activate's three parameters are typed against your manifest — a views block naming
type: "component" gives you a codelet.window.registerComponentView that knows about it, for
example. Nothing here is required to run; skip it and the object still works, just untyped.
#The manifest
manifest is plain data — no function to call, nothing to run — and that's the point. It's what
a server reads to build the activity bar, the panel, the palette and every view's heading without
executing a line of your extension. activate is the other half, and it only ever runs in a
browser.
import { renderWorkbench } from "codelet/workbench/server";
import { Workbench } from "codelet/workbench";
import { EXTENSIONS } from "./extensions.ts";
// On the server: reads every manifest, runs nothing.
const html = renderWorkbench({ extensions: EXTENSIONS });
// In the browser: same list, and now activate() runs for each one.
const workbench = new Workbench({ parent, extensions: EXTENSIONS });Warning
Pass the exact same extensions array, in the same order, to both calls. The server's markup and
the client's first render have to agree, or hydration rebuilds the shell instead of taking it
over.
A manifest's top-level fields are its identity:
| Field | Type | Default | Description |
|---|---|---|---|
name | string | — | The extension's id. |
displayName | string | name | Shown in place of name in the Extensions view. |
description | string | — | One line under the name. |
icon | string | — | A URL, drawn beside the name in the Extensions view. |
homepage | string | — | Where this is written up. |
workspace | "showing" \| "home" | "showing" | Which tree this extension reads and writes — see below. |
Most extensions never set workspace. "showing" follows the reader: if they open a second
workspace over their own, your extension follows them into it. "home" pins an extension to the
reader's own tree regardless of what's opened over it — for something that has to keep mirroring
the reader's files no matter what's showing, the way codelet/extensions/remote does.
Everything else lives under contributes, and every point in it is optional.
#viewsContainers
An icon in the activity bar, a tab in the panel, or a pane in the secondary side bar.
contributes: {
viewsContainers: {
activitybar: [{ id: "todo", title: "To Do", icon: "check" }],
},
},| Field | Type | Default | Description |
|---|---|---|---|
id | string | — | Referenced by views, keyed to this id. |
title | string | — | The container's heading. |
icon | IconName | — | One of codelet's own icon names. |
order | number | — | Where it sits among its peers. Lower is nearer the top or the start. |
end | boolean | false | Activity bar only: the foot of the bar, beside the theme control. |
activitybar, panel and secondary are the three places a container can go; declaring one in
one place shows it only there.
#views
Rows inside a container, keyed by the container's id.
views: {
todo: [{ id: "todo.list", name: "To Do", filter: { placeholder: "Filter" } }],
},id, name and type ("tree" by default, or "webview" / "component") are the shape;
filter, select, tagged and colored are the chrome a view can ask for. All of it, and how
to fill a view with rows, a frame or a component, is Views.
#viewsWelcome
Markdown shown in place of a view's rows while it has none.
viewsWelcome: [{ view: "todo.list", contents: "No items yet.\n\n[Add one](command:todo.add)" }],[text](command:id) runs a command on click. Covered fully, including when and group, on
Views.
#commands
What the command palette lists.
commands: [{ command: "todo.add", title: "Add To Do", icon: "check" }],A declared command shows in the palette once its extension also calls
vscode.commands.registerCommand with the same id. The full story — including commands that take
arguments, which stay undeclared — is Commands and menus.
#menus
Commands placed somewhere other than the palette: a button on the tab bar, an entry in a context menu, a row over a view's own contents.
menus: {
"editor/title": [{ command: "todo.add", when: "editorLangId == markdown", group: "navigation" }],
},Every place codelet draws one, the when keys available there, and the argument a command gets
when it runs from one, is Commands and menus.
#customEditors
A file shown as something other than text.
customEditors: [
{
viewType: "media.image",
displayName: "Image Preview",
selector: [{ filenamePattern: "**/*.png" }],
},
],| Field | Type | Description |
|---|---|---|
viewType | string | Passed to registerCustomEditorProvider. |
displayName | string | Shown in "Reopen Editor With…". |
selector | { filenamePattern: string }[] | Globs matched against the path. |
The first declaration whose selector matches a file wins. More on custom editors and webview panels is on Editors.
#terminal.profiles
A shell "New Terminal" can open.
contributes: { terminal: { profiles: [{ id: "my-shell", title: "My Shell" }] } },Answered by calling window.registerTerminalProfileProvider for the same id in activate.
This only does anything if the workbench also carries a terminal extension
(codelet/extensions/terminal) to draw the tab in — see Tasks, source control and terminals.
#configuration
The settings this extension reads, and what they are by default.
contributes: {
configuration: {
title: "Markdown",
properties: { "markdown.preview.theme": { type: "string", default: "auto" } },
},
},properties is keyed by the full dotted name. default is the only member codelet reads back —
see Settings your extension declares below.
#languages
A language codelet doesn't already know.
contributes: {
languages: [{ id: "svelte", extensions: [".svelte"], aliases: ["Svelte"] }],
},| Field | Type | Description |
|---|---|---|
id | string | What TextDocument.languageId and a selector's language say. |
extensions | string[]? | File extensions, dot included. Only the last one in a name is read. |
filenames | string[]? | Whole names matched before any extension — Gemfile, Cargo.lock. |
aliases | string[]? | The first is the display label; the rest are for matching elsewhere. |
A manifest naming a language codelet already has adds its names and aliases to that one, rather than declaring a second language with the same id.
#directories
Directories made in the tree before anything has filled them.
contributes: { directories: ["/workspace"] },Applied where a workbench is built — both new Workbench() and renderWorkbench() — rather than
in activate, so a folder whose contents arrive later (over a socket, say) is a real folded row
from the first paint instead of appearing once something has connected. A path that already has
something in it is left alone.
#codelet's own additions
filter, select, tagged and colored on a view; type: "component"; secondary and end
on a container; and directories are codelet's own additions to VS Code's contribution schema.
VS Code ignores manifest keys it doesn't recognize, so a manifest using any of these stays a
valid VS Code extension — it just contributes nothing extra there.
#Activation
activate(context, vscode, codelet) runs once per extension, and only in a browser — never
during renderWorkbench() on a server, and never twice for the same extension unless it was
stopped and started again.
context.subscriptions is an array you push anything disposable onto: event listeners, tree
views, status bar items, registered commands, output channels. Everything pushed there is
disposed together the moment the extension is stopped — from the built-in Extensions view, or a
call to codelet.extensions.setActive(id, false). Starting it again builds a fresh
ExtensionContext and runs activate from the top.
activate(context, vscode) {
const item = vscode.window.createStatusBarItem();
item.show();
context.subscriptions.push(item);
},
deactivate() {
// Runs immediately before subscriptions are disposed. For cleanup that isn't itself
// disposable — closing a socket, say.
},deactivate() is optional, and runs right before that teardown.
#The extension context
context carries state that belongs to the workbench rather than to one activation — stop an
extension and start it again, and it reads back what it wrote.
await context.globalState.update("lastOpened", path);
context.globalState.get<string>("lastOpened");
await context.workspaceState.update("expanded", ids); // scoped to the tree you're in
await context.secrets.store("token", value); // never written down in the clear
const token = await context.secrets.get("token");globalState and workspaceState are Mementos — get, update, keys() — and JSON,
surviving a reload in localStorage unless the host keeps them elsewhere. workspaceState is
per tree: only the reader's own tree is written down, since a workspace another extension opened
is a different tree under the same name on the next visit.
extensionMode is always ExtensionMode.Production, and extension is the same ExtensionInfo
vscode.extensions.getExtension would answer for this extension, isActive live on it.
Warning
Write a token to secrets, never to globalState. What secrets guarantees depends on the
host: by default codelet keeps secrets in memory and forgets them when the tab closes, because a
page can't encrypt anything on its own — writing one to globalState would put it in the clear
under an API that says otherwise. A host that hands the workbench a sealed store makes secrets
survive a reload under the reader's own passkey; either way, secrets is where the guarantee
lives.
Warning
Don't read a secret during activate. On a host that seals them, the first question any
extension asks can prompt the reader for a passkey — while the page is still loading, for
something nobody has looked at yet. Read one when the reader reaches for the thing that needs it:
a command they ran, a tab they opened.
#Settings your extension declares
contributes.configuration declares a setting and its default; workspace.getConfiguration
reads it back, layered under whatever the reader has written over it.
manifest: {
contributes: {
configuration: {
properties: { "markdown.preview.theme": { type: "string", default: "auto" } },
},
},
},
async activate(context, vscode) {
const config = vscode.workspace.getConfiguration();
const theme = config.get<string>("markdown.preview.theme");
await config.update("markdown.preview.theme", "dark");
},Defaults are read with the rest of the manifest's static contributions, not at activation, so a
key means what its manifest says whether or not the extension that declared it is currently
running. The full WorkspaceConfiguration API — sections, inspect(), change events — is
Workspace.