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 has one editor group, so "the editor" is one file at a time and "the tabs" are everything open beside it. This page covers reading and writing that file, drawing over it, reading the tab strip, and the four things a tab can show instead of text: a webview, a custom editor, a diff, or a real page served elsewhere.

#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. onDidChangeActiveTextEditor fires on a tab switch; window.visibleTextEditors is the same editor in a one-item array (or empty), since there is only ever one editor group.

A TextDocument is read-only data about a file:

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. A workbench has one TabGroup, so all is a one-item array and activeTabGroup is always that group; what moves is its tabs and activeTab. Tab.input is one of four classes, told apart with instanceof rather than switched on a field — the same shape VS Code uses so a fifth 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.

tabGroups.close(tab | tabs) closes one or several, asking about anything unsaved once for the whole set; it resolves false if the reader cancelled. isPinned is always false — there's no pinning gesture here.

#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, one editor group having no column to open beside; reveal() selects the tab, dispose() closes it.

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.

#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/webcontainer), 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.