Codelet logoCodelet

Views

Tree views, webviews, component views, decorations and the chrome codelet adds

A view is a pane inside a container — the activity bar, the panel, or the secondary side bar. This page covers the three kinds a view can be, the chrome around one, and the two calls that show something outside any container at all: file decorations and a list of locations.

#Containers and views

contributes.viewsContainers declares where a container sits — activitybar, panel or secondary — and contributes.views, keyed by container id, declares what's in it:

contributes: {
  viewsContainers: {
    activitybar: [{ id: "todo", title: "To Do", icon: "check" }],
  },
  views: {
    todo: [{ id: "todo.list", name: "To Do", type: "tree" }],
  },
},

type picks the kind: "tree" (the default) fills a view from a TreeDataProvider, "webview" from a sandboxed document, "component" from a real component the workbench mounts directly. A container is its views — one whose every view fails its when shows no icon and no tab at all. The rest of a container's and a view's fields are on Anatomy of an extension.

#Tree views

import { defineExtension } from "codelet/extensions";

export const todo = defineExtension({
  manifest: {
    name: "todo",
    contributes: {
      viewsContainers: { activitybar: [{ id: "todo", title: "To Do", icon: "check" }] },
      views: { todo: [{ id: "todo.list", name: "To Do" }] },
    },
  },
  activate(context, vscode) {
    context.subscriptions.push(
      vscode.window.registerTreeDataProvider("todo.list", {
        getChildren: () => ["Buy milk", "Write docs"],
        getTreeItem: (label) => new vscode.TreeItem(label),
      }),
    );
  },
});

registerTreeDataProvider(viewId, provider) fills a "tree" view and returns a Disposable. createTreeView(viewId, options) does the same and hands back a TreeView you can also set message, title, description or badge on — the same split registerComponentView / createComponentView follows for a component view.

A TreeDataProvider<T> has four members:

MemberDescription
getChildren(element?)The root's children with no argument, or that element's.
getTreeItem(element)The TreeItem to draw for it.
getParent(element)?Its parent, for TreeView.reveal to walk up. Without it, reveal only finds root rows.
resolveTreeItem(item, element, token)?Fills in the rest of a row once the pointer reaches it — a tooltip, a command.
onDidChangeTreeData?Fires to redraw. undefined/an element/an array redraws everything.

A TreeItem carries the row itself:

FieldTypeDescription
labelstring \| TreeItemLabelThe row's text, or { label, highlights } for ranges to underline.
idstring?Keys expansion state across a refetch, else the row's path from root does.
iconPathstring \| ThemeIconA name, or a ThemeIcon — never an image.
descriptionstring \| booleanMuted text after the label.
resourceUriUri?Gives the row a file icon and picks up file decorations.
tooltipstring?Plain text.
commandCommand?Runs on click.
collapsibleStateTreeItemCollapsibleStateNone, Collapsed or Expanded.
contextValuestring?Read as viewItem by a view/item/context menu — see Commands and menus.
checkboxStateTreeItemCheckboxState?Unchecked or Checked — a checkbox before the row.
accessibilityInformation{ label, role? }?What a screen reader says, when the label alone isn't it.

Checking a row's box fires TreeView.onDidChangeCheckboxState — nothing redraws it for you, the provider's onDidChangeTreeData still has to.

const changed = new vscode.EventEmitter<void>();

vscode.window.registerTreeDataProvider("todo.list", {
  onDidChangeTreeData: changed.event,
  getChildren: () => items,
  getTreeItem: (item) => {
    const row = new vscode.TreeItem(item.label, vscode.TreeItemCollapsibleState.None);
    row.checkboxState = item.done
      ? vscode.TreeItemCheckboxState.Checked
      : vscode.TreeItemCheckboxState.Unchecked;
    row.contextValue = item.done ? "done" : "open";
    return row;
  },
});

// Later, once something changes:
changed.fire();

A TreeView returned by createTreeView carries the chrome above the rows and the state a mounted pane holds:

const view = vscode.window.createTreeView("todo.list", { treeDataProvider: provider });
view.message = "3 open";
view.badge = { value: 3, tooltip: "3 open items" };

message is a line above the rows, title renames the heading, description reads beside it, and badge marks the container's icon. selection, onDidChangeSelection, visible and onDidChangeVisibility all describe the pane while it's mounted, and read as empty/false while it isn't — a folded sidebar has no rows to select.

codelet/extensions/search (Search) is a worked tree view with a filter box and a two-level tree of files and matches:

#Drag and drop

createTreeView's dragAndDropController makes a tree's rows draggable and lets it accept a drop:

vscode.window.createTreeView("todo.list", {
  treeDataProvider: provider,
  dragAndDropController: {
    dropMimeTypes: ["application/vnd.code.tree.todo.list"],
    dragMimeTypes: [],
    handleDrop(target, dataTransfer) {
      const dragged = dataTransfer.get("application/vnd.code.tree.todo.list")?.value;
      // …
    },
  },
});

Any controller makes the rows draggable, whether or not it implements handleDrag: the tree's own mime type, application/vnd.code.tree.<viewId>, always carries the elements themselves — nothing is serialized, since drag and drop happens inside one page. A drop only lands on a row, never on empty space below the tree.

#View chrome codelet adds

filter and select in a manifest add a search box and a dropdown above a view's rows, read back through codelet.views.filter(viewId) and codelet.views.select(viewId):

views: {
  todo: [{
    id: "todo.list",
    name: "To Do",
    filter: { placeholder: "Filter" },
    select: { options: [{ value: "all", label: "All" }, { value: "open", label: "Open" }] },
  }],
},
activate(context, vscode, codelet) {
  const filter = codelet.views.filter("todo.list");
  const select = codelet.views.select("todo.list");
  context.subscriptions.push(
    filter!.onDidChange(() => refresh()),
    select!.onDidChange(() => refresh()),
  );
},

Both are { value, onDidChange }filter/select return undefined for a view that didn't declare one. select's first option is what the dropdown opens on.

tagged and colored change how a row is drawn rather than adding chrome: tagged says a row's highlighted range is a name it leads with rather than a search match, so it's brought forward out of the muted text instead of underlined; colored paints a row whose ThemeIcon names a colour in that colour entirely, label included, rather than the glyph alone. codelet/extensions/logs (Logs) uses both, alongside a select dropdown for severity:

#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 when clicked. when scopes a welcome the same way any other context expression does, and group@order decides which shows first where more than one holds — ungrouped comes last. view can also name "explorer", the shell's own file tree, for a welcome over the workspace itself rather than a contributed view.

#Webview views

A "webview" view is filled by a WebviewViewProvider, resolved the first time it mounts:

views: { todo: [{ id: "todo.list", name: "To Do", type: "webview" }] },
activate(context, vscode) {
  context.subscriptions.push(
    vscode.window.registerWebviewViewProvider("todo.list", {
      resolveWebviewView(view) {
        view.webview.options = { enableScripts: true };
        view.webview.html = `<button onclick="alert('hi')">Click</button>`;
        view.webview.onDidReceiveMessage((message) => console.log(message));
      },
    }),
  );
},

webview.html is a whole document in a sandboxed frame with no access to the page around it — postMessage(message) from either side and onDidReceiveMessage on the other are the only way across. WebviewOptions controls what the sandbox allows:

OptionTypeDefaultDescription
enableScriptsbooleanfalseLets the document run scripts.
enableFormsbooleanenableScripts's valueLets a form in the document submit.
enableCommandUrisboolean \| readonly string[]falsecommand: links: every command, or only these.
localResourceRootsUri[]?everything workspace.fs reachesNarrows what asWebviewUri may resolve.

webview.asWebviewUri(uri) turns a file: path into an address this document can load — an extension's own stylesheet or image. There's no server behind it: every such address found in the document is rewritten, as the frame is built, into a data: URI of the file's actual bytes, so cspSource (for a Content-Security-Policy the document writes) is literally data:. That's also its limit — an address built at runtime and handed to the frame over postMessage, rather than written directly into the document, resolves nothing: there's no real directory behind any of this for such a request to reach.

codelet/extensions/terminal (Terminal) is a webview view, one xterm per tab, filled this way.

#Component views

The third kind, and codelet's own: type: "component" plus codelet.window.registerComponentView, mounting a real component where the workbench would otherwise mount a frame.

views: { todo: [{ id: "todo.list", name: "To Do", type: "component" }] },
activate(context, vscode, codelet) {
  context.subscriptions.push(codelet.window.registerComponentView("todo.list", TodoPane));
},

TodoPane is a plain component of your own — JSX, types shared with the extension that owns it, no postMessage between the two halves of one view. It takes no props, since a view's own state belongs to the extension that registered it:

import { useState } from "preact/hooks";

function TodoPane() {
  const [items, setItems] = useState<string[]>([]);
  return (
    <ul>
      {items.map((item) => (
        <li>{item}</li>
      ))}
    </ul>
  );
}

createComponentView(viewId, component) is the same registration, returning a ComponentView you can also set description and badge on — there's no title (rename the container in the manifest instead) and no message (a component draws its own).

Warning

A component view only works where preact is one instance. Today that means an extension bundled with the workbench's own build, or a build that resolves codelet to this repo's source — not an extension installed from npm, whose own preact is a second copy that breaks hooks against the workbench's. WebviewView is the surface to use for an extension published on its own.

codelet/extensions/scm (Scm) draws its whole pane this way — a component rather than a tree, since a commit box and a row's own buttons are chrome a TreeDataProvider has no way to ask for. codelet/extensions/extensions (Extensions) is a simpler one, listing what the workbench is running:

#File decorations

registerFileDecorationProvider badges and colours a file wherever the explorer, or a contributed tree whose rows carry resourceUri, draws it:

context.subscriptions.push(
  vscode.window.registerFileDecorationProvider({
    provideFileDecoration(uri) {
      if (!modified.has(uri.fsPath)) return;
      return new vscode.FileDecoration(
        "M",
        "Modified",
        new vscode.ThemeColor("gitDecoration.modifiedResourceForeground"),
      );
    },
  }),
);

A FileDecoration is a badge (a letter or two), a tooltip, a color (a ThemeColor, not a raw value), and propagate — whether a directory takes the colour of a decoration under it. Answer synchronously and a row is decorated on first paint; answer with a promise and the row stays plain until you fire onDidChangeFileDecorations.

codelet.window.decorationOf(path) is the reading half, for a pane drawing its own rows of files that should wear the same badges the explorer does — the letter a source control pane draws beside a changed file's name is read this way, off whichever provider answered first:

const badge = codelet.window.decorationOf(path)?.badge;

#A list of locations

codelet.window.registerLocationsView is where a list of places goes — the one language gesture that doesn't open a single file, References being the case. There's no built-in peek widget; the reader gets whatever pane registers here instead:

context.subscriptions.push(
  codelet.window.registerLocationsView((title, locations) => {
    // title: "References to greet", locations: readonly Location[]
  }),
);

codelet/extensions/search is the registrant that ships — the same tree the Search view already draws, so a reference search lands in familiar rows. Registering nothing here means "Go to References" simply has nowhere to send its answer, so the row is missing from the editor's context menu rather than doing nothing when clicked.

Right-click greet in either file and choose Go to References — the Search view swaps to the list registerLocationsView was handed.