Codelet logoCodelet

Editors, tabs and webviews

Reading and editing the open document, decorations, the tab strip, and every kind of panel a tab can show

A workbench starts with one editor group and may come to have several — a tab the reader takes out into a window floating over the shell is a group of its own. "The editor" is still one file at a time, the one in the group the reader is in; "the tabs" are everything open across all of them. This page covers reading and writing that file, drawing over it, reading the tab strips, and the five things a tab can show instead of text: a webview, a custom editor, a diff, a real page served elsewhere, or a terminal.

#The active editor and its document

const editor = vscode.window.activeTextEditor;
if (editor) {
  const { document } = editor;
  console.log(document.languageId, document.lineCount, document.getText().length);
}

context.subscriptions.push(
  vscode.window.onDidChangeActiveTextEditor((editor) => {
    console.log(editor ? `now showing ${editor.document.uri.fsPath}` : "nothing showing");
  }),
);

window.activeTextEditor is the file the reader is looking at, or undefined when nothing is open — one file however many groups are open, the one in the group they are in. onDidChangeActiveTextEditor fires on a tab switch, and on a move between groups; window.visibleTextEditors is that same editor in a one-item array (or empty). That is a narrowing rather than a count of what's on screen: a second group may well be showing a file of its own, and this list won't name it.

A TextDocument is read-only data about a file:

Every `TextDocument` member
MemberDescription
uri, fileName, languageIdWhere it is and what it's treated as.
version, lineCountBumps on every edit; how many lines.
isDirty, isClosedUnsaved dot showing; no longer open in a tab.
eolEndOfLine.LF or .CRLF, read off the first break.
getText(range?)The whole file, or a span of it.
lineAt(lineOrPosition)A TextLine: text, range, firstNonWhitespaceCharacterIndex.
positionAt(offset) / offsetAt(position)Convert between an offset and a Position.
getWordRangeAtPosition(position, regex?)The word under a position, or undefined.
save()Same as Mod-S on this file.

isUntitled is always false — every document is a file in the tree or one a provider serves, never a buffer with nowhere to save to.

Warning

TextEditor.selection and .selections are readable, not writable — the caret belongs to the reader. There's no revealRange, insertSnippet, or way to move the cursor from an extension.

window.showTextDocument(uri, options?) opens a file into the tab strip and resolves with its TextEditor:

await vscode.window.showTextDocument(vscode.Uri.file("/README.md"), {
  selection: new vscode.Range(new vscode.Position(0, 0), new vscode.Position(0, 0)),
  preview: false,
});

selection scrolls to and highlights a range. preview: true (the default) opens into the one replaceable tab every sidebar click uses; pass false for a tab that stays put, which matters when you're opening several files at once — one preview slot can't hold two of them.

#Editing text

TextEditor.edit() is the single-document half of an edit — fill the builder, and every replace/insert/delete in it lands as one transaction:

await vscode.window.activeTextEditor?.edit((edit) => {
  edit.insert(new vscode.Position(0, 0), "// generated\n");
  edit.replace(new vscode.Range(new vscode.Position(2, 0), new vscode.Position(2, 5)), "const");
});

For a change that spans files — a rename refactor, moving a symbol into a new file — build a WorkspaceEdit and hand it to workspace.applyEdit:

const oldUri = vscode.Uri.file("/src/old-name.ts");
const newUri = vscode.Uri.file("/src/new-name.ts");

const edit = new vscode.WorkspaceEdit();
edit.createFile(newUri, { overwrite: false });
edit.insert(newUri, new vscode.Position(0, 0), "export const value = 1;\n");
edit.deleteFile(oldUri, { ignoreIfNotExists: true });

const applied = await vscode.workspace.applyEdit(edit);

Every resource's edits land as one transaction, so carets, decorations and squiggles in that file move with the text rather than jumping when it's rewritten whole. createFile, deleteFile and renameFile sit in the same edit and run in the order you called them — a createFile followed by an insert into that path is one gesture, not two. applyEdit resolves false (rather than throwing) for a Uri outside the tree, or a file operation the tree refuses: creating over an existing file, deleting nothing, renaming onto something already there — unless you pass the matching ignoreIf…/overwrite option. Two edits touching the same span reject the whole call.

#Decorations

const todo = vscode.window.createTextEditorDecorationType({
  backgroundColor: "rgba(255, 200, 0, 0.15)",
  isWholeLine: true,
  after: { contentText: " ← TODO", color: "#c58a00" },
});

const editor = vscode.window.activeTextEditor;
if (editor) {
  const ranges: vscode.Range[] = [];
  for (let line = 0; line < editor.document.lineCount; line++) {
    if (editor.document.lineAt(line).text.includes("TODO")) {
      ranges.push(editor.document.lineAt(line).range);
    }
  }
  editor.setDecorations(todo, ranges);
}

createTextEditorDecorationType returns a handle; TextEditor.setDecorations(type, ranges) paints it over those ranges of the active file, replacing whatever it drew last time — an empty array takes it off. Disposing the type clears it from every file at once.

Every render option is a CSS declaration on the mark: color, backgroundColor, border, opacity, textDecoration, and so on, plus before/after for content drawn beside the range (what a peer's caret label is built from). isWholeLine paints the line rather than the span — what a blame or coverage gutter needs. light/dark override the base for one theme.

Left out: gutterIconPath (no gutter slot to paint in — the quick diff bars are the workbench's own column), overviewRulerColor (no overview ruler), and rangeBehavior. Colours here are plain CSS strings, not ThemeColor — a decoration type needs one colour per instance, which a fixed theme-colour table can't express.

#The tabs API

for (const tab of vscode.window.tabGroups.activeTabGroup.tabs) {
  if (tab.input instanceof vscode.TabInputText) {
    console.log(tab.label, tab.input.uri.fsPath, tab.isDirty);
  }
}

context.subscriptions.push(
  vscode.window.tabGroups.onDidChangeTabs(({ opened, closed }) => {
    console.log(`opened ${opened.length}, closed ${closed.length}`);
  }),
);

window.tabGroups is what's open, as against activeTextEditor's one file. all is every strip in the order it's drawn — the docked one, then any floating windows — and activeTabGroup is the one the reader is in. Tab.group is the strip a tab belongs to, so the same file open in two of them is two Tabs told apart by that, and onDidChangeTabGroups is a window opening, closing, or becoming the one the reader is in. Tab.input is one of five classes, told apart with instanceof rather than switched on a field — the same shape VS Code uses so a sixth kind can be added later without breaking every reader of Tab:

InputWhat it shows
TabInputTextA document as text — a tree file, or one a provider serves.
TabInputTextDiffTwo documents side by side — what vscode.diff opens.
TabInputCustomA file a customEditors claim shows itself.
TabInputWebviewNo file at all — what createWebviewPanel opened.
TabInputTerminalA shell in the editor area rather than in the panel.

tabGroups.close(tab | tabs) closes one or several, asking about anything unsaved once for the whole set; a set spanning two groups is one question per group, asked one after the other, and it resolves false if the reader cancelled any of them. isPinned is always false — there's no pinning gesture here.

Note

A group beyond the first is a window the reader opened: Move into New Window on a tab, or View: Move Editor into New Window in the palette (neither offered on a narrow viewport, where the shell shows one thing at a time). There's no API for one — an extension can run workbench.action.moveEditorToNewWindow or …copyEditorToNewWindow through executeCommand like any other command, but nothing names a window, places one, or opens a file into a chosen group. Where things open stays the reader's.

#Webview panels

const panel = vscode.window.createWebviewPanel(
  "myExt.preview",
  "Preview",
  vscode.ViewColumn.Beside,
  {
    enableScripts: true,
  },
);

panel.webview.html = `<!doctype html><body>
  <button id="go">Send</button>
  <script>
    document.getElementById("go").onclick = () => acquireVsCodeApi().postMessage("hi");
  </script>
</body>`;

panel.webview.onDidReceiveMessage((message) => console.log("from the panel:", message));
context.subscriptions.push(panel.onDidDispose(() => console.log("closed")));

A webview panel is a tab with no file under it — a document you write, sandboxed in an <iframe> with no access to the page around it. postMessage/onDidReceiveMessage is the only way across the boundary in either direction. showOptions (VS Code's ViewColumn) is taken and never read: a panel opens in the group the reader is in, and there's no column an extension addresses even when several are open. reveal() selects the tab in whichever group holds the panel, dispose() closes it wherever it is.

enableScripts opens allow-scripts; it never opens allow-same-origin, so even a scripted panel has an opaque origin — no cookies, no storage, no reach into the document that embeds it. enableForms defaults to match enableScripts. enableCommandUris makes command:id links in the document run a workbench command when clicked; pass true for any command or a list to narrow it.

To load a file of your own — a stylesheet, a script, an image — resolve its address first:

const styleUri = panel.webview.asWebviewUri(vscode.Uri.file("/media/preview.css"));
panel.webview.html = `<link rel="stylesheet" href="${styleUri}">`;

asWebviewUri only resolves addresses that end up written into the document — one built at runtime and sent over postMessage, or named in a stylesheet's own url(), can't be resolved this way, there being no server behind it to ask. localResourceRoots narrows what's reachable; without it, a panel can load anything workspace.fs reaches.

Note

This is about a webview panel, a tab. A webview view — a pane in the sidebar or the panel, filled by registerWebviewViewProvider — is covered on Views.

Try it live — codelet/extensions/markdown opens exactly this kind of panel from the preview icon in the tab bar:

#Custom editors

import { defineExtension } from "codelet/extensions";

export const svgPreview = defineExtension({
  manifest: {
    name: "svg-preview",
    contributes: {
      customEditors: [
        {
          viewType: "svgPreview.view",
          displayName: "SVG Preview",
          selector: [{ filenamePattern: "**/*.svg" }],
        },
      ],
    },
  },
  activate(context, vscode) {
    context.subscriptions.push(
      vscode.window.registerCustomEditorProvider("svgPreview.view", {
        openCustomDocument: (uri) => ({ uri, dispose: () => {} }),
        resolveCustomEditor: async (document, panel) => {
          const text = (await vscode.workspace.openTextDocument(document.uri)).getText();
          panel.webview.html = `<!doctype html><body>${text}</body>`;
        },
      }),
    );
  },
});

A custom editor is what a file shows in place of the editor when text isn't the point — an image, a diagram. contributes.customEditors declares which files it claims by glob; the first matching declaration wins. registerCustomEditorProvider fills it in: openCustomDocument answers a CustomDocument (usually the uri alone, plus a no-op dispose), and resolveCustomEditor writes the panel's webview.html, the same webview a panel gets.

Readonly is the only kind — there's no edit stack for a custom editor to push into, since the tree holds the truth and the editor writes straight through to it. The tab bar's "Reopen Editor With…" is the way back to plain text.

One file open in two groups is two editors, as it is in VS Code: two frames, two panels, and a resolveCustomEditor for each. Unlike VS Code, openCustomDocument is called per editor too rather than once for the file — so a provider that keeps per-document state should key it on the uri rather than assume one CustomDocument per path, and dispose it per document. Closing one of the two takes that frame and leaves the other drawing.

#Browser panels

const panel = codelet.window.createBrowserPanel("https://example.com", "Preview");
panel.onDidDispose(() => console.log("closed"));
// later, once the server has restarted:
panel.reload();

codelet.window.createBrowserPanel(url, title) opens a tab with a real, cross-origin <iframe> pointed at a page somebody else is serving — a dev server running in the reader's own page (codelet/extensions/bash), or anything else already reachable. There's no vscode equivalent: a webview here is always sandboxed without allow-same-origin, and a nested frame inherits that sandbox, so a real dev server loaded inside a webview would run at an opaque origin with cookies and storage silently refused. A browser panel sits outside any sandbox instead — reach for it whenever the tab is pointing at a page that already runs at its own origin, and a webview whenever you're writing the document yourself.

The trade is everything a webview's boundary buys: no postMessage, no injected theme, no way to read the frame's contents. BrowserPanel is url (read/write — writing the address it already holds is a no-op, since a live reload wants an explicit reload()), reload(), reveal(), dispose(), onDidDispose.

#Diffs

await vscode.commands.executeCommand(
  "vscode.diff",
  vscode.Uri.file("/src/before.ts"),
  vscode.Uri.file("/src/after.ts"),
  "before.ts ↔ after.ts",
);

vscode.diff is a built-in command, not a call of its own — run it with executeCommand and it opens a tab comparing the two documents, TabInputTextDiff in the tabs API. Either side can be a scheme a registerTextDocumentContentProvider serves, which is how a source control extension diffs a working file against a revision that isn't in the tree.

The quick diff gutter — the bars beside the line numbers showing what changed against a baseline — is drawn from a SourceControl's quickDiffProvider, not from anything on this page. See Tasks, source control and terminals.

#Saving, dirty marks and read-only

context.subscriptions.push(
  vscode.workspace.onWillSaveTextDocument((event) => {
    if (event.document.languageId !== "typescript") return;
    event.waitUntil(formatEdits(event.document));
  }),
  vscode.workspace.onDidSaveTextDocument((document) => {
    console.log("saved", document.uri.fsPath);
  }),
);

onWillSaveTextDocument is format-on-save's moment: call waitUntil with a promise of TextEdit[], and those edits land before the unsaved dot is dropped. onDidSaveTextDocument fires after — the workbench writes through, so the text was already the tree's before the save happened; this is the dot going away.

If your extension pushes a file's contents somewhere the tree doesn't know about, two calls on codelet.workspace keep the tab honest:

try {
  await push(path, text);
  codelet.workspace.markSaved(path);
} catch {
  codelet.workspace.markSaved(path, false); // put the dot back — the push failed
}

markSaved(path, saved = true) is the same mark the workbench's own Mod-S makes; call it with false when a push fails, since the tree already holds the text and the tab has already stopped showing it as unsaved by the time you know the push didn't land. setReadonly(path, readonly) stops the editor from taking edits to a file that can't really be written where it lives — the tab and the footer say why, and workspace.fs still writes it underneath, which is how the file got its text in the first place.