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:

  1. Listen for onDidChangeActiveTextEditor
  2. When a markdown file opens, call openPreview() then workbench.action.closeActiveEditor

This broke badly:

  • Race condition: Closing the editor fires another onDidChangeActiveTextEditor as focus shifts, creating a loop
  • Missing toolbar: editor/title menu 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

PropertyHow it works
File associationThe webview tab IS the editor for the file - shows filename, dirty dot, save all work natively
Undo/redoUses VS Code’s normal text-document undo stack - no hacks needed
EditsUse WorkspaceEdit to modify the underlying document from the webview
SynconDidChangeTextDocument fires when the document changes (from undo, other extensions, etc.)
No race conditionsVS 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

ApproachCustomTextEditorProviderWebviewPanel + closeEditor
Tab represents the fileYes - filename, dirty dot, saveNo - it’s a “panel”, not an editor
Undo/redoNative document undo stackMust hack: focus editor, run undo, refocus panel
Race conditionsNone - VS Code manages lifecycleYes - closing editor fires events that re-trigger
Toolbar iconsWork (it’s an editor context)Disappear (no active editor)
File dirty indicatorAutomaticMust 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.