
# Editors, tabs and webviews

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

```ts
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`:

```ts
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:

```ts
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`:

```ts
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

```ts
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

```ts
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

```ts
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:

```ts
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](/api/views).
::

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

::codelet-playground{mode="workbench" extensions="markdown-preview" active="notes.md" height="360"}

```md [notes.md]
# Notes

A webview panel is a tab with **no file** under it — click the preview icon above to open one.
```

::

## Custom editors

```ts
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.

::codelet-playground{mode="workbench" extensions="media" active="mark.svg" height="320"}

```xml [mark.svg]
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><circle cx="32" cy="32" r="28" fill="currentColor"/></svg>
```

::

## Browser panels

```ts
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

```ts
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](/api/integrations).

## Saving, dirty marks and read-only

```ts
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:

```ts
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.
