While building Ace - a VS Code extension that renders annotated markdown in a preview webview - I went down the wrong path first. Here’s what broke and what fixed it.
The Problem
I wanted a “preview-only” mode: opening a .md file shows only the rendered preview, no code editor. The naive approach:
- Listen for
onDidChangeActiveTextEditor - When a markdown file opens, call
openPreview()thenworkbench.action.closeActiveEditor
This broke badly:
- Race condition: Closing the editor fires another
onDidChangeActiveTextEditoras focus shifts, creating a loop - Missing toolbar:
editor/titlemenu contributions require an active editor - no editor means no toolbar icon - Broken undo: The document’s undo stack is tied to the editor context
- Glitchy tabs: Preview panels flickered in and out as VS Code tried to reconcile the state
The Solution: CustomTextEditorProvider
VS Code has a purpose-built API for this: CustomTextEditorProvider. It replaces the default text editor with a webview while keeping the file backed by a normal TextDocument.
Key Properties
| Property | How it works |
|---|---|
| File association | The webview tab IS the editor for the file - shows filename, dirty dot, save all work natively |
| Undo/redo | Uses VS Code’s normal text-document undo stack - no hacks needed |
| Edits | Use WorkspaceEdit to modify the underlying document from the webview |
| Sync | onDidChangeTextDocument fires when the document changes (from undo, other extensions, etc.) |
| No race conditions | VS Code manages the lifecycle - no closing/reopening tabs |
Implementation Pattern
package.json - register the custom editor:
{
"contributes": {
"customEditors": [
{
"viewType": "myext.previewEditor",
"displayName": "My Preview Editor",
"selector": [{ "filenamePattern": "*.md" }],
"priority": "option"
}
]
}
}
priority: "option" = opt-in via right-click -> “Open With…”. Use "default" to replace the text editor entirely.
extension.ts - register the provider:
vscode.window.registerCustomEditorProvider(
'myext.previewEditor',
new MyEditorProvider(context),
{
webviewOptions: { retainContextWhenHidden: true },
supportsMultipleEditorsPerDocument: true,
}
);
Provider class - implement resolveCustomTextEditor:
class MyEditorProvider implements vscode.CustomTextEditorProvider {
async resolveCustomTextEditor(
document: vscode.TextDocument,
webviewPanel: vscode.WebviewPanel,
_token: vscode.CancellationToken,
): Promise<void> {
webviewPanel.webview.options = { enableScripts: true };
const updateWebview = () => {
webviewPanel.webview.html = renderPreview(document.getText());
};
// document changes -> update preview
const changeSub = vscode.workspace.onDidChangeTextDocument((e) => {
if (e.document.uri.toString() === document.uri.toString()) {
updateWebview();
}
});
// edits from webview -> modify document
const msgSub = webviewPanel.webview.onDidReceiveMessage(async (msg) => {
const edit = new vscode.WorkspaceEdit();
edit.replace(document.uri, someRange, newText);
await vscode.workspace.applyEdit(edit);
// Undo works automatically because it's a standard WorkspaceEdit
});
webviewPanel.onDidDispose(() => {
changeSub.dispose();
msgSub.dispose();
});
updateWebview();
}
}
Why WebviewPanel + Close Editor Fails
| Approach | CustomTextEditorProvider | WebviewPanel + closeEditor |
|---|---|---|
| Tab represents the file | Yes - filename, dirty dot, save | No - it’s a “panel”, not an editor |
| Undo/redo | Native document undo stack | Must hack: focus editor, run undo, refocus panel |
| Race conditions | None - VS Code manages lifecycle | Yes - closing editor fires events that re-trigger |
| Toolbar icons | Work (it’s an editor context) | Disappear (no active editor) |
| File dirty indicator | Automatic | Must implement yourself |
Broader Lesson
When you need a VS Code webview that replaces the default editor for a file type, use CustomTextEditorProvider - not a WebviewPanel with tab-closing hacks. The API exists specifically for this case. The priority: "option" flag makes it non-destructive: users can still open files in the regular text editor when they want to.
For side-by-side preview (editor + preview), WebviewPanel with ViewColumn.Beside is still the right approach. Both can coexist in the same extension.
This is the backstory for Ace - if you want to see what the API enables in practice.