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:
| Member | Description |
|---|---|
uri, fileName, languageId | Where it is and what it's treated as. |
version, lineCount | Bumps on every edit; how many lines. |
isDirty, isClosed | Unsaved dot showing; no longer open in a tab. |
eol | EndOfLine.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:
| Input | What it shows |
|---|---|
TabInputText | A document as text — a tree file, or one a provider serves. |
TabInputTextDiff | Two documents side by side — what vscode.diff opens. |
TabInputCustom | A file a customEditors claim shows itself. |
TabInputWebview | No 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.