Codelet logoCodelet

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:

FieldTypeDefaultDescription
namestringThe extension's id.
displayNamestringnameShown in place of name in the Extensions view.
descriptionstringOne line under the name.
iconstringA URL, drawn beside the name in the Extensions view.
homepagestringWhere 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" }],
  },
},
FieldTypeDefaultDescription
idstringReferenced by views, keyed to this id.
titlestringThe container's heading.
iconIconNameOne of codelet's own icon names.
ordernumberWhere it sits among its peers. Lower is nearer the top or the start.
endbooleanfalseActivity 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.

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" }],
  },
],
FieldTypeDescription
viewTypestringPassed to registerCustomEditorProvider.
displayNamestringShown 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"] }],
},
FieldTypeDescription
idstringWhat TextDocument.languageId and a selector's language say.
extensionsstring[]?File extensions, dot included. Only the last one in a name is read.
filenamesstring[]?Whole names matched before any extension — Gemfile, Cargo.lock.
aliasesstring[]?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.