From 008b84945661ea41aebdfe68ec64c16810afeb6c Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Wed, 25 Mar 2026 21:58:44 +0100 Subject: [PATCH 1/6] A full proposed extension API was added so that extensions providing Custom Editors can also populate the Outline view --- custom-editor-outline.md | 476 ++++++++++++++++++ src/vs/platform/actions/common/actions.ts | 2 + .../common/extensionsApiProposals.ts | 3 + .../api/browser/extensionHost.contribution.ts | 1 + .../browser/mainThreadCustomEditorOutline.ts | 159 ++++++ .../workbench/api/common/extHost.api.impl.ts | 5 + .../workbench/api/common/extHost.protocol.ts | 17 + .../api/common/extHostCustomEditorOutline.ts | 87 ++++ .../browser/customEditor.contribution.ts | 1 + .../browser/customEditorOutline.ts | 400 +++++++++++++++ .../common/customEditorOutlineService.ts | 42 ++ .../contrib/outline/browser/outlinePane.ts | 24 + .../actions/common/menusExtensionPoint.ts | 12 + .../services/outline/browser/outline.ts | 3 + .../vscode.proposed.customEditorOutline.d.ts | 124 +++++ 15 files changed, 1356 insertions(+) create mode 100644 custom-editor-outline.md create mode 100644 src/vs/workbench/api/browser/mainThreadCustomEditorOutline.ts create mode 100644 src/vs/workbench/api/common/extHostCustomEditorOutline.ts create mode 100644 src/vs/workbench/contrib/customEditor/browser/customEditorOutline.ts create mode 100644 src/vs/workbench/contrib/customEditor/common/customEditorOutlineService.ts create mode 100644 src/vscode-dts/vscode.proposed.customEditorOutline.d.ts diff --git a/custom-editor-outline.md b/custom-editor-outline.md new file mode 100644 index 00000000000000..13ff9927fa782b --- /dev/null +++ b/custom-editor-outline.md @@ -0,0 +1,476 @@ +# Custom Editor Outline API + +> **Proposed API** — requires `"enabledApiProposals": ["customEditorOutline"]` in your extension's `package.json`. +> +> Implements [vscode#97095](https://github.com/microsoft/vscode/issues/97095). + +## What Was Done (VS Code Side) + +A full proposed extension API was added so that extensions providing **Custom Editors** can also populate the **Outline view**, **Breadcrumbs**, and **Go to Symbol** quick-pick with their own tree of items — with support for active element tracking, reveal-on-click, context menus, and inline toolbar buttons. + +### Architecture Overview + +``` +┌──────────────────────┐ ┌─────────────────────────────┐ +│ Extension Host │ │ Main Thread (renderer) │ +│ │ RPC │ │ +│ ExtHostCustomEditor │◄────────►│ MainThreadCustomEditor │ +│ Outline │ │ Outline │ +│ │ │ │ │ +│ Your extension calls │ │ ▼ │ +│ registerCustomEditor │ │ ICustomEditorOutline │ +│ OutlineProvider(...) │ │ ProviderService │ +│ │ │ │ │ +└──────────────────────┘ │ ▼ │ + │ CustomEditorExtensionOutline│ + │ (IOutline<> impl) │ + │ │ │ + │ ▼ │ + │ Outline Pane / Breadcrumbs│ + └─────────────────────────────┘ +``` + +### Files Created / Modified + +| File | Purpose | +|------|---------| +| `src/vscode-dts/vscode.proposed.customEditorOutline.d.ts` | Proposed API types (`CustomEditorOutlineItem`, `CustomEditorOutlineProvider`, `window.registerCustomEditorOutlineProvider`) | +| `src/vs/workbench/api/common/extHost.protocol.ts` | Protocol shapes (`MainThreadCustomEditorOutlineShape`, `ExtHostCustomEditorOutlineShape`, `ICustomEditorOutlineItemDto`) and proxy identifiers | +| `src/vs/workbench/api/common/extHostCustomEditorOutline.ts` | Extension host implementation — converts items to DTOs and forwards to main thread | +| `src/vs/workbench/api/browser/mainThreadCustomEditorOutline.ts` | Main thread handler — manages provider service, registers singleton | +| `src/vs/workbench/contrib/customEditor/common/customEditorOutlineService.ts` | `ICustomEditorOutlineProviderService` interface and `ICustomEditorOutlineItemDto` DTO type | +| `src/vs/workbench/contrib/customEditor/browser/customEditorOutline.ts` | `IOutline` implementation with custom tree renderer, context key binding, toolbar, and the `IOutlineCreator` | +| `src/vs/workbench/api/common/extHost.api.impl.ts` | Wired `registerCustomEditorOutlineProvider` onto the `window` namespace | +| `src/vs/platform/extensions/common/extensionsApiProposals.ts` | Registered the `customEditorOutline` proposal name | +| `src/vs/platform/actions/common/actions.ts` | Added `MenuId.CustomEditorOutlineActionMenu` (toolbar) and `MenuId.CustomEditorOutlineContext` (right-click) | +| `src/vs/workbench/services/actions/common/menusExtensionPoint.ts` | Registered `customEditor/outline/toolbar` and `customEditor/outline/context` as extension menu contribution points | +| `src/vs/workbench/services/outline/browser/outline.ts` | Added `contextMenuId` and `getContextKeyOverlay` to `IOutlineListConfig` | +| `src/vs/workbench/contrib/outline/browser/outlinePane.ts` | Added right-click context menu support with context key overlay | +| `src/vs/workbench/contrib/customEditor/browser/customEditor.contribution.ts` | Added import to register the outline creator | +| `src/vs/workbench/api/browser/extensionHost.contribution.ts` | Added import for `mainThreadCustomEditorOutline` | + +--- + +## How to Use It in Your Extension + +### 1. Enable the Proposed API + +In your extension's `package.json`, add: + +```json +{ + "enabledApiProposals": ["customEditorOutline"] +} +``` + +### 2. Register the Outline Provider + +Call `vscode.window.registerCustomEditorOutlineProvider(viewType, provider)` where `viewType` matches your custom editor's `viewType` from `package.json`. + +```typescript +import * as vscode from 'vscode'; + +export function activate(context: vscode.ExtensionContext) { + const outlineProvider = new MyOutlineProvider(); + + context.subscriptions.push( + vscode.window.registerCustomEditorOutlineProvider( + 'myExtension.myCustomEditor', // must match your customEditors viewType + outlineProvider, + ) + ); +} +``` + +### 3. Implement `CustomEditorOutlineProvider` + +```typescript +class MyOutlineProvider implements vscode.CustomEditorOutlineProvider { + + // --- Events --- + + private readonly _onDidChangeOutline = new vscode.EventEmitter(); + readonly onDidChangeOutline = this._onDidChangeOutline.event; + + private readonly _onDidChangeActiveItem = new vscode.EventEmitter(); + readonly onDidChangeActiveItem = this._onDidChangeActiveItem.event; + + // --- State (updated from your webview) --- + + private _items: vscode.CustomEditorOutlineItem[] = []; + + // --- Provider methods --- + + provideOutline(token: vscode.CancellationToken): vscode.CustomEditorOutlineItem[] { + return this._items; + } + + revealItem(itemId: string): void { + // Called when the user clicks an outline node. + // Post a message to your webview to scroll to / select the element. + this._currentWebview?.postMessage({ + type: 'revealElement', + id: itemId, + }); + } + + // --- Methods you call from your code --- + + /** Call when the document structure changes */ + updateStructure(items: vscode.CustomEditorOutlineItem[]): void { + this._items = items; + this._onDidChangeOutline.fire(); // triggers a refresh in the Outline view + } + + /** Call when the user selects/focuses a different element in the webview */ + setActiveElement(itemId: string | undefined): void { + this._onDidChangeActiveItem.fire(itemId); // highlights the item in the Outline + } +} +``` + +### 4. Build Outline Items + +Each `CustomEditorOutlineItem` has: + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| `id` | `string` | ✅ | Unique identifier. Used for active tracking and reveal. | +| `label` | `string` | ✅ | Display text in the outline tree. | +| `detail` | `string` | | Secondary text shown after the label. | +| `tooltip` | `string` | | Tooltip on hover. | +| `icon` | `ThemeIcon` | | Icon, e.g. `new vscode.ThemeIcon('symbol-class')`. | +| `contextValue` | `string` | | Used for menu `when` clauses (bound to `customEditorOutlineItem`). | +| `children` | `CustomEditorOutlineItem[]` | | Nested child items to form a tree. | + +Example: + +```typescript +const items: vscode.CustomEditorOutlineItem[] = [ + { + id: 'page-1', + label: 'Page 1', + icon: new vscode.ThemeIcon('file'), + contextValue: 'page', + children: [ + { + id: 'button-1', + label: 'Submit Button', + icon: new vscode.ThemeIcon('symbol-event'), + contextValue: 'element', + }, + { + id: 'textbox-1', + label: 'Name Input', + icon: new vscode.ThemeIcon('symbol-field'), + contextValue: 'element', + }, + ], + }, +]; +``` + +### 5. Connect Your Webview + +Your webview should post messages when the structure changes or the user selects an element: + +```typescript +// In your CustomTextEditorProvider.resolveCustomTextEditor(): +webviewPanel.webview.onDidReceiveMessage(message => { + switch (message.type) { + case 'structureChanged': + outlineProvider.updateStructure(message.items); + break; + case 'selectionChanged': + outlineProvider.setActiveElement(message.elementId); + break; + } +}); +``` + +And your webview should listen for reveal requests: + +```javascript +// Inside the webview +window.addEventListener('message', event => { + const message = event.data; + if (message.type === 'revealElement') { + const element = document.getElementById(message.id); + element?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + selectElement(element); // your own selection logic + } +}); +``` + +### 6. Add Toolbar Buttons and Context Menus + +The `contextValue` property on each outline item is bound to the context key `customEditorOutlineItem`. You can use this in `when` clauses to contribute actions to two separate menus: + +| Menu ID | Purpose | +|---------|---------| +| `customEditor/outline/toolbar` | Inline icon buttons and "..." overflow menu on each row | +| `customEditor/outline/context` | Right-click context menu on each row | + +This separation lets you configure completely different actions for the toolbar and the context menu. The same command can appear in both menus. + +#### 6a. Declare Commands + +```json +{ + "contributes": { + "commands": [ + { "command": "myExt.deleteElement", "title": "Delete", "icon": "$(trash)" }, + { "command": "myExt.addChild", "title": "Add Child", "icon": "$(add)" }, + { "command": "myExt.renameElement", "title": "Rename" }, + { "command": "myExt.duplicateElement", "title": "Duplicate" } + ] + } +} +``` + +#### 6b. Contribute to Menus + +Within `customEditor/outline/toolbar`, items in the `inline` group become always-visible icon buttons. Items outside `inline` go into the "..." overflow dropdown. + +All items in `customEditor/outline/context` appear in the right-click context menu. + +```json +{ + "contributes": { + "menus": { + "customEditor/outline/toolbar": [ + { + "command": "myExt.deleteElement", + "when": "customEditorOutlineItem == 'element'", + "group": "inline" + }, + { + "command": "myExt.addChild", + "when": "customEditorOutlineItem == 'container'", + "group": "inline" + } + ], + "customEditor/outline/context": [ + { + "command": "myExt.renameElement", + "when": "customEditorOutlineItem =~ /element|container/" + }, + { + "command": "myExt.duplicateElement", + "when": "customEditorOutlineItem == 'element'" + }, + { + "command": "myExt.deleteElement", + "when": "customEditorOutlineItem == 'element'" + } + ] + } + } +} +``` + +#### 6c. Register Command Handlers + +```typescript +export function activate(context: vscode.ExtensionContext) { + // ... register outline provider (see above) ... + + context.subscriptions.push( + vscode.commands.registerCommand('myExt.deleteElement', () => { + webviewPanel.webview.postMessage({ type: 'deleteSelected' }); + }), + vscode.commands.registerCommand('myExt.renameElement', () => { + webviewPanel.webview.postMessage({ type: 'renameSelected' }); + }), + ); +} +``` + +#### Where Actions Appear + +| Menu ID | `group` value | Appearance | +|---------|---------------|------------| +| `customEditor/outline/toolbar` | `"inline"` | Icon button on the **right side** of the outline row (visible on hover) | +| `customEditor/outline/toolbar` | `"inline@1"`, `"inline@2"` | Inline buttons with explicit ordering | +| `customEditor/outline/toolbar` | *(omitted or other)* | Entry in the **"..." overflow** dropdown | +| `customEditor/outline/context` | *(any)* | Entry in the **right-click context menu** | + +--- + +## Full Minimal Example + +### `package.json` + +```json +{ + "name": "visual-designer-outline-example", + "version": "0.0.1", + "engines": { "vscode": "^1.113.0" }, + "enabledApiProposals": ["customEditorOutline"], + "activationEvents": [], + "main": "./out/extension.js", + "contributes": { + "customEditors": [{ + "viewType": "designer.visualEditor", + "displayName": "Visual Designer", + "selector": [{ "filenamePattern": "*.design" }] + }], + "menus": { + "customEditor/outline/toolbar": [ + { + "command": "designer.deleteElement", + "when": "customEditorOutlineItem == 'widget'", + "group": "inline" + } + ], + "customEditor/outline/context": [ + { + "command": "designer.deleteElement", + "when": "customEditorOutlineItem == 'widget'" + } + ] + }, + "commands": [ + { "command": "designer.deleteElement", "title": "Delete", "icon": "$(trash)" } + ] + } +} +``` + +### `src/extension.ts` + +```typescript +import * as vscode from 'vscode'; + +let outlineProvider: DesignerOutlineProvider; + +export function activate(context: vscode.ExtensionContext) { + outlineProvider = new DesignerOutlineProvider(); + + context.subscriptions.push( + vscode.window.registerCustomEditorProvider( + 'designer.visualEditor', + new DesignerEditorProvider(context, outlineProvider), + ), + vscode.window.registerCustomEditorOutlineProvider( + 'designer.visualEditor', + outlineProvider, + ), + vscode.commands.registerCommand('designer.deleteElement', () => { + vscode.window.showInformationMessage('Delete element'); + }), + ); +} + +// ─── Outline Provider ────────────────────────────────────────── + +class DesignerOutlineProvider implements vscode.CustomEditorOutlineProvider { + private readonly _onDidChangeOutline = new vscode.EventEmitter(); + readonly onDidChangeOutline = this._onDidChangeOutline.event; + + private readonly _onDidChangeActiveItem = new vscode.EventEmitter(); + readonly onDidChangeActiveItem = this._onDidChangeActiveItem.event; + + private _items: vscode.CustomEditorOutlineItem[] = []; + private _webview: vscode.Webview | undefined; + + setWebview(webview: vscode.Webview): void { + this._webview = webview; + } + + updateItems(items: vscode.CustomEditorOutlineItem[]): void { + this._items = items; + this._onDidChangeOutline.fire(); + } + + setActive(itemId: string | undefined): void { + this._onDidChangeActiveItem.fire(itemId); + } + + provideOutline(_token: vscode.CancellationToken): vscode.CustomEditorOutlineItem[] { + return this._items; + } + + revealItem(itemId: string): void { + this._webview?.postMessage({ type: 'reveal', id: itemId }); + } +} + +// ─── Custom Editor Provider ──────────────────────────────────── + +class DesignerEditorProvider implements vscode.CustomTextEditorProvider { + constructor( + private readonly context: vscode.ExtensionContext, + private readonly outline: DesignerOutlineProvider, + ) {} + + async resolveCustomTextEditor( + document: vscode.TextDocument, + webviewPanel: vscode.WebviewPanel, + ): Promise { + webviewPanel.webview.options = { enableScripts: true }; + this.outline.setWebview(webviewPanel.webview); + + // Provide some initial outline items + this.outline.updateItems([ + { + id: 'root', + label: 'Canvas', + icon: new vscode.ThemeIcon('layout'), + contextValue: 'canvas', + children: [ + { + id: 'btn-1', + label: 'Button', + icon: new vscode.ThemeIcon('symbol-event'), + contextValue: 'widget', + }, + { + id: 'input-1', + label: 'Text Input', + icon: new vscode.ThemeIcon('symbol-field'), + contextValue: 'widget', + }, + ], + }, + ]); + + // Listen for webview messages + webviewPanel.webview.onDidReceiveMessage(msg => { + if (msg.type === 'selectionChanged') { + this.outline.setActive(msg.id); + } + }); + + webviewPanel.webview.html = ` + +

Visual Designer

+

This is a placeholder for your visual designer webview.

+ + `; + } +} +``` + +--- + +## Summary of Features + +| Feature | How It Works | +|---------|-------------| +| **Outline tree** | `provideOutline()` returns your items → they appear in the Outline view | +| **Active element tracking** | Fire `onDidChangeActiveItem` with an item `id` → that node gets highlighted | +| **Reveal on click** | User clicks outline node → `revealItem(id)` is called → you scroll the webview | +| **Breadcrumbs** | Automatically built from the active item's parent chain | +| **Go to Symbol** | Items appear in the Ctrl+Shift+O quick-pick | +| **Inline buttons** | Contribute to `customEditor/outline/toolbar` with `"group": "inline"` | +| **Overflow menu** | Contribute to `customEditor/outline/toolbar` without `inline` group | +| **Context menu** | Contribute to `customEditor/outline/context` | +| **When clauses** | Use `customEditorOutlineItem == 'yourContextValue'` to scope actions | diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index c2dcf733944351..54fab34522c6f4 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -222,6 +222,8 @@ export class MenuId { static readonly NotebookOutputToolbar = new MenuId('NotebookOutputToolbar'); static readonly NotebookOutlineFilter = new MenuId('NotebookOutlineFilter'); static readonly NotebookOutlineActionMenu = new MenuId('NotebookOutlineActionMenu'); + static readonly CustomEditorOutlineActionMenu = new MenuId('CustomEditorOutlineActionMenu'); + static readonly CustomEditorOutlineContext = new MenuId('CustomEditorOutlineContext'); static readonly NotebookEditorLayoutConfigure = new MenuId('NotebookEditorLayoutConfigure'); static readonly NotebookKernelSource = new MenuId('NotebookKernelSource'); static readonly BulkEditTitle = new MenuId('BulkEditTitle'); diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index a3f632a34cde00..4cb098cc3979f6 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -209,6 +209,9 @@ const _allApiProposals = { customEditorMove: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.customEditorMove.d.ts', }, + customEditorOutline: { + proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.customEditorOutline.d.ts', + }, dataChannels: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.dataChannels.d.ts', }, diff --git a/src/vs/workbench/api/browser/extensionHost.contribution.ts b/src/vs/workbench/api/browser/extensionHost.contribution.ts index 6a5528bdf1337f..f2845f37fcdb25 100644 --- a/src/vs/workbench/api/browser/extensionHost.contribution.ts +++ b/src/vs/workbench/api/browser/extensionHost.contribution.ts @@ -73,6 +73,7 @@ import './mainThreadPower.js'; import './mainThreadWebviewManager.js'; import './mainThreadWorkspace.js'; import './mainThreadComments.js'; +import './mainThreadCustomEditorOutline.js'; import './mainThreadNotebook.js'; import './mainThreadNotebookKernels.js'; import './mainThreadNotebookDocumentsAndEditors.js'; diff --git a/src/vs/workbench/api/browser/mainThreadCustomEditorOutline.ts b/src/vs/workbench/api/browser/mainThreadCustomEditorOutline.ts new file mode 100644 index 00000000000000..f91e95a7573de1 --- /dev/null +++ b/src/vs/workbench/api/browser/mainThreadCustomEditorOutline.ts @@ -0,0 +1,159 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../base/common/cancellation.js'; +import { Emitter, Event } from '../../../base/common/event.js'; +import { Disposable, DisposableMap, IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { InstantiationType, registerSingleton } from '../../../platform/instantiation/common/extensions.js'; +import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js'; +import { ExtHostContext, ExtHostCustomEditorOutlineShape, MainContext, MainThreadCustomEditorOutlineShape } from '../common/extHost.protocol.js'; +import { ICustomEditorOutlineItemDto, ICustomEditorOutlineProviderService } from '../../contrib/customEditor/common/customEditorOutlineService.js'; + +class CustomEditorOutlineProviderEntry { + private readonly _onDidChangeOutline = new Emitter(); + readonly onDidChangeOutline = this._onDidChangeOutline.event; + + private readonly _onDidChangeActiveItem = new Emitter(); + readonly onDidChangeActiveItem = this._onDidChangeActiveItem.event; + + private _activeItemId: string | undefined; + + get activeItemId(): string | undefined { return this._activeItemId; } + + fireDidChangeOutline(): void { + this._onDidChangeOutline.fire(); + } + + fireDidChangeActiveItem(itemId: string | undefined): void { + this._activeItemId = itemId; + this._onDidChangeActiveItem.fire(itemId); + } + + dispose(): void { + this._onDidChangeOutline.dispose(); + this._onDidChangeActiveItem.dispose(); + } +} + +class CustomEditorOutlineProviderService extends Disposable implements ICustomEditorOutlineProviderService { + declare readonly _serviceBrand: undefined; + + private readonly _entries = this._register(new DisposableMap()); + + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange: Event = this._onDidChange.event; + + private _provideOutline?: (viewType: string, token: CancellationToken) => Promise; + private _revealItem?: (viewType: string, itemId: string) => void; + + setDelegate(delegate: { + provideOutline: (viewType: string, token: CancellationToken) => Promise; + revealItem: (viewType: string, itemId: string) => void; + }): void { + this._provideOutline = delegate.provideOutline; + this._revealItem = delegate.revealItem; + } + + hasProvider(viewType: string): boolean { + return this._entries.has(viewType); + } + + getProviderViewTypes(): string[] { + return [...this._entries.keys()]; + } + + async provideOutline(viewType: string, token: CancellationToken): Promise { + if (this._provideOutline) { + return this._provideOutline(viewType, token); + } + return undefined; + } + + revealItem(viewType: string, itemId: string): void { + if (this._revealItem) { + this._revealItem(viewType, itemId); + } + } + + getActiveItemId(viewType: string): string | undefined { + return this._entries.get(viewType)?.activeItemId; + } + + onDidChangeOutline(viewType: string): Event { + const entry = this._entries.get(viewType); + return entry ? entry.onDidChangeOutline : Event.None; + } + + onDidChangeActiveItem(viewType: string): Event { + const entry = this._entries.get(viewType); + return entry ? entry.onDidChangeActiveItem : Event.None; + } + + registerProvider(viewType: string): IDisposable { + const entry = new CustomEditorOutlineProviderEntry(); + this._entries.set(viewType, entry); + this._onDidChange.fire(); + return toDisposable(() => { + this._entries.deleteAndDispose(viewType); + this._onDidChange.fire(); + }); + } + + unregisterProvider(viewType: string): void { + this._entries.deleteAndDispose(viewType); + this._onDidChange.fire(); + } + + fireDidChangeOutline(viewType: string): void { + this._entries.get(viewType)?.fireDidChangeOutline(); + } + + fireDidChangeActiveItem(viewType: string, itemId: string | undefined): void { + this._entries.get(viewType)?.fireDidChangeActiveItem(itemId); + } +} + +registerSingleton(ICustomEditorOutlineProviderService, CustomEditorOutlineProviderService, InstantiationType.Delayed); + +@extHostNamedCustomer(MainContext.MainThreadCustomEditorOutline) +export class MainThreadCustomEditorOutline extends Disposable implements MainThreadCustomEditorOutlineShape { + + private readonly _proxy: ExtHostCustomEditorOutlineShape; + private readonly _registrations = this._register(new DisposableMap()); + + constructor( + context: IExtHostContext, + @ICustomEditorOutlineProviderService private readonly _service: ICustomEditorOutlineProviderService, + ) { + super(); + this._proxy = context.getProxy(ExtHostContext.ExtHostCustomEditorOutline); + + // Wire the service delegate to call through to the ext host + if (this._service instanceof CustomEditorOutlineProviderService) { + this._service.setDelegate({ + provideOutline: (viewType, token) => this._proxy.$provideOutline(viewType, token), + revealItem: (viewType, itemId) => this._proxy.$revealItem(viewType, itemId), + }); + } + } + + $registerCustomEditorOutlineProvider(viewType: string): void { + const registration = this._service.registerProvider(viewType); + this._registrations.set(viewType, registration); + } + + $unregisterCustomEditorOutlineProvider(viewType: string): void { + this._registrations.deleteAndDispose(viewType); + this._service.unregisterProvider(viewType); + } + + $onDidChangeOutline(viewType: string): void { + this._service.fireDidChangeOutline(viewType); + } + + $onDidChangeActiveItem(viewType: string, itemId: string | undefined): void { + this._service.fireDidChangeActiveItem(viewType, itemId); + } +} diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index ebe8580ca5d3a2..ea66107446db77 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -48,6 +48,7 @@ import { IExtHostCommands } from './extHostCommands.js'; import { createExtHostComments } from './extHostComments.js'; import { ExtHostConfigProvider, IExtHostConfiguration } from './extHostConfiguration.js'; import { ExtHostCustomEditors } from './extHostCustomEditors.js'; +import { ExtHostCustomEditorOutline } from './extHostCustomEditorOutline.js'; import { IExtHostDataChannels } from './extHostDataChannels.js'; import { IExtHostDebugService } from './extHostDebugService.js'; import { IExtHostDecorations } from './extHostDecorations.js'; @@ -232,6 +233,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostWebviews = rpcProtocol.set(ExtHostContext.ExtHostWebviews, new ExtHostWebviews(rpcProtocol, initData.remote, extHostWorkspace, extHostLogService, extHostApiDeprecation)); const extHostWebviewPanels = rpcProtocol.set(ExtHostContext.ExtHostWebviewPanels, new ExtHostWebviewPanels(rpcProtocol, extHostWebviews, extHostWorkspace)); const extHostCustomEditors = rpcProtocol.set(ExtHostContext.ExtHostCustomEditors, new ExtHostCustomEditors(rpcProtocol, extHostDocuments, extensionStoragePaths, extHostWebviews, extHostWebviewPanels)); + const extHostCustomEditorOutline = rpcProtocol.set(ExtHostContext.ExtHostCustomEditorOutline, new ExtHostCustomEditorOutline(rpcProtocol)); const extHostWebviewViews = rpcProtocol.set(ExtHostContext.ExtHostWebviewViews, new ExtHostWebviewViews(rpcProtocol, extHostWebviews)); const extHostTesting = rpcProtocol.set(ExtHostContext.ExtHostTesting, accessor.get(IExtHostTesting)); const extHostUriOpeners = rpcProtocol.set(ExtHostContext.ExtHostUriOpeners, new ExtHostUriOpeners(rpcProtocol)); @@ -981,6 +983,9 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I registerCustomEditorProvider: (viewType: string, provider: vscode.CustomTextEditorProvider | vscode.CustomReadonlyEditorProvider, options: { webviewOptions?: vscode.WebviewPanelOptions; supportsMultipleEditorsPerDocument?: boolean } = {}) => { return extHostCustomEditors.registerCustomEditorProvider(extension, viewType, provider, options); }, + registerCustomEditorOutlineProvider: (viewType: string, provider: vscode.CustomEditorOutlineProvider) => { + return extHostCustomEditorOutline.registerCustomEditorOutlineProvider(extension, viewType, provider); + }, registerFileDecorationProvider(provider: vscode.FileDecorationProvider) { return extHostDecorations.registerFileDecorationProvider(provider, extension); }, diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 8282d175cde94f..49ca168c915cd1 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -101,6 +101,7 @@ import { TerminalShellExecutionCommandLineConfidence } from './extHostTypes.js'; import * as tasks from './shared/tasks.js'; import { PromptsType } from '../../contrib/chat/common/promptSyntax/promptTypes.js'; import { CDPEvent, CDPRequest, CDPResponse } from '../../../platform/browserView/common/cdp/types.js'; +import { ICustomEditorOutlineItemDto } from '../../contrib/customEditor/common/customEditorOutlineService.js'; export type IconPathDto = | UriComponents @@ -1168,6 +1169,20 @@ export interface ExtHostCustomEditorsShape { $onMoveCustomEditor(handle: WebviewHandle, newResource: UriComponents, viewType: string): Promise; } +export type { ICustomEditorOutlineItemDto } from '../../contrib/customEditor/common/customEditorOutlineService.js'; + +export interface MainThreadCustomEditorOutlineShape extends IDisposable { + $registerCustomEditorOutlineProvider(viewType: string): void; + $unregisterCustomEditorOutlineProvider(viewType: string): void; + $onDidChangeOutline(viewType: string): void; + $onDidChangeActiveItem(viewType: string, itemId: string | undefined): void; +} + +export interface ExtHostCustomEditorOutlineShape { + $provideOutline(viewType: string, token: CancellationToken): Promise; + $revealItem(viewType: string, itemId: string): void; +} + export interface ExtHostWebviewViewsShape { $resolveWebviewView(webviewHandle: WebviewHandle, viewType: string, title: string | undefined, state: any, cancellation: CancellationToken): Promise; @@ -3895,6 +3910,7 @@ export const MainContext = { MainThreadWebviewPanels: createProxyIdentifier('MainThreadWebviewPanels'), MainThreadWebviewViews: createProxyIdentifier('MainThreadWebviewViews'), MainThreadCustomEditors: createProxyIdentifier('MainThreadCustomEditors'), + MainThreadCustomEditorOutline: createProxyIdentifier('MainThreadCustomEditorOutline'), MainThreadUrls: createProxyIdentifier('MainThreadUrls'), MainThreadUriOpeners: createProxyIdentifier('MainThreadUriOpeners'), MainThreadProfileContentHandlers: createProxyIdentifier('MainThreadProfileContentHandlers'), @@ -4013,5 +4029,6 @@ export const ExtHostContext = { ExtHostDataChannels: createProxyIdentifier('ExtHostDataChannels'), ExtHostChatSessions: createProxyIdentifier('ExtHostChatSessions'), ExtHostGitExtension: createProxyIdentifier('ExtHostGitExtension'), + ExtHostCustomEditorOutline: createProxyIdentifier('ExtHostCustomEditorOutline'), ExtHostBrowsers: createProxyIdentifier('ExtHostBrowsers'), }; diff --git a/src/vs/workbench/api/common/extHostCustomEditorOutline.ts b/src/vs/workbench/api/common/extHostCustomEditorOutline.ts new file mode 100644 index 00000000000000..efb04133d56759 --- /dev/null +++ b/src/vs/workbench/api/common/extHostCustomEditorOutline.ts @@ -0,0 +1,87 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type * as vscode from 'vscode'; +import { CancellationToken } from '../../../base/common/cancellation.js'; +import { DisposableStore, toDisposable } from '../../../base/common/lifecycle.js'; +import { ExtHostCustomEditorOutlineShape, ICustomEditorOutlineItemDto, MainContext, MainThreadCustomEditorOutlineShape } from './extHost.protocol.js'; +import { IExtensionDescription } from '../../../platform/extensions/common/extensions.js'; +import { checkProposedApiEnabled } from '../../services/extensions/common/extensions.js'; +import { ThemeIcon } from '../../../base/common/themables.js'; +import { IRPCProtocol } from '../../services/extensions/common/proxyIdentifier.js'; + +export class ExtHostCustomEditorOutline implements ExtHostCustomEditorOutlineShape { + + private readonly _proxy: MainThreadCustomEditorOutlineShape; + private readonly _providers = new Map(); + + constructor( + mainContext: IRPCProtocol, + ) { + this._proxy = mainContext.getProxy(MainContext.MainThreadCustomEditorOutline); + } + + registerCustomEditorOutlineProvider( + extension: IExtensionDescription, + viewType: string, + provider: vscode.CustomEditorOutlineProvider, + ): vscode.Disposable { + checkProposedApiEnabled(extension, 'customEditorOutline'); + + if (this._providers.has(viewType)) { + throw new Error(`An outline provider for custom editor view type '${viewType}' is already registered`); + } + + const disposables = new DisposableStore(); + + this._providers.set(viewType, { provider, disposables }); + this._proxy.$registerCustomEditorOutlineProvider(viewType); + + disposables.add(provider.onDidChangeOutline(() => { + this._proxy.$onDidChangeOutline(viewType); + })); + + disposables.add(provider.onDidChangeActiveItem(itemId => { + this._proxy.$onDidChangeActiveItem(viewType, itemId); + })); + + return toDisposable(() => { + this._providers.delete(viewType); + disposables.dispose(); + this._proxy.$unregisterCustomEditorOutlineProvider(viewType); + }); + } + + async $provideOutline(viewType: string, token: CancellationToken): Promise { + const entry = this._providers.get(viewType); + if (!entry) { + return undefined; + } + const items = await entry.provider.provideOutline(token); + if (!items) { + return undefined; + } + return items.map(item => this._convertItem(item)); + } + + $revealItem(viewType: string, itemId: string): void { + const entry = this._providers.get(viewType); + if (entry) { + entry.provider.revealItem(itemId); + } + } + + private _convertItem(item: vscode.CustomEditorOutlineItem): ICustomEditorOutlineItemDto { + return { + id: item.id, + label: item.label, + detail: item.detail, + tooltip: item.tooltip, + icon: ThemeIcon.isThemeIcon(item.icon) ? item.icon : undefined, + contextValue: item.contextValue, + children: item.children?.map(child => this._convertItem(child)), + }; + } +} diff --git a/src/vs/workbench/contrib/customEditor/browser/customEditor.contribution.ts b/src/vs/workbench/contrib/customEditor/browser/customEditor.contribution.ts index ab9cf7ccf961a4..1c92a44017f053 100644 --- a/src/vs/workbench/contrib/customEditor/browser/customEditor.contribution.ts +++ b/src/vs/workbench/contrib/customEditor/browser/customEditor.contribution.ts @@ -14,6 +14,7 @@ import { ICustomEditorService } from '../common/customEditor.js'; import { WebviewEditor } from '../../webviewPanel/browser/webviewEditor.js'; import { CustomEditorInput } from './customEditorInput.js'; import { CustomEditorService } from './customEditors.js'; +import './customEditorOutline.js'; registerSingleton(ICustomEditorService, CustomEditorService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/customEditor/browser/customEditorOutline.ts b/src/vs/workbench/contrib/customEditor/browser/customEditorOutline.ts new file mode 100644 index 00000000000000..fcf2685468ecd6 --- /dev/null +++ b/src/vs/workbench/contrib/customEditor/browser/customEditorOutline.ts @@ -0,0 +1,400 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../base/browser/dom.js'; +import { ToolBar } from '../../../../base/browser/ui/toolbar/toolbar.js'; +import { IIconLabelValueOptions, IconLabel } from '../../../../base/browser/ui/iconLabel/iconLabel.js'; +import { IKeyboardNavigationLabelProvider, IListVirtualDelegate } from '../../../../base/browser/ui/list/list.js'; +import { IListAccessibilityProvider } from '../../../../base/browser/ui/list/listWidget.js'; +import { IDataSource, ITreeNode, ITreeRenderer } from '../../../../base/browser/ui/tree/tree.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { FuzzyScore, createMatches } from '../../../../base/common/filters.js'; +import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { URI } from '../../../../base/common/uri.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { IEditorOptions } from '../../../../platform/editor/common/editor.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; +import { IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { IMenuService, MenuId, MenuItemAction } from '../../../../platform/actions/common/actions.js'; +import { MenuEntryActionViewItem, getActionBarActions } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; +import { localize } from '../../../../nls.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { IBreadcrumbsDataSource, IOutline, IOutlineComparator, IOutlineCreator, IOutlineListConfig, IOutlineService, OutlineChangeEvent, OutlineConfigCollapseItemsValues, OutlineConfigKeys, OutlineTarget } from '../../../services/outline/browser/outline.js'; +import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions } from '../../../common/contributions.js'; +import { LifecyclePhase } from '../../../services/lifecycle/common/lifecycle.js'; +import { IEditorPane } from '../../../common/editor.js'; +import { CustomEditorInput } from './customEditorInput.js'; +import { ICustomEditorOutlineItemDto, ICustomEditorOutlineProviderService } from '../common/customEditorOutlineService.js'; +import '../../../contrib/codeEditor/browser/outline/documentSymbolsTree.css'; // reuse outline-element styles + +// ----- Outline item wrapper ----- + +class CustomEditorOutlineEntry { + + readonly children: CustomEditorOutlineEntry[] = []; + + constructor( + readonly id: string, + readonly label: string, + readonly detail: string | undefined, + readonly tooltip: string | undefined, + readonly icon: ThemeIcon | undefined, + readonly contextValue: string | undefined, + readonly parent: CustomEditorOutlineEntry | undefined, + ) { } +} + +// ----- Context keys ----- + +const CustomEditorOutlineContext = { + ContextValue: new RawContextKey('customEditorOutlineItem', ''), +}; + +// ----- Renderer (reuses outline-element CSS from documentSymbolsTree.css) ----- + +interface ICustomEditorOutlineTemplate { + readonly container: HTMLElement; + readonly iconClass: HTMLElement; + readonly iconLabel: IconLabel; + readonly actionMenu: HTMLElement; + readonly elementDisposables: DisposableStore; +} + +class CustomEditorOutlineRenderer implements ITreeRenderer { + + readonly templateId = 'CustomEditorOutlineRenderer'; + + constructor( + private readonly _target: OutlineTarget, + @IContextMenuService private readonly _contextMenuService: IContextMenuService, + @IContextKeyService private readonly _contextKeyService: IContextKeyService, + @IMenuService private readonly _menuService: IMenuService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + ) { } + + renderTemplate(container: HTMLElement): ICustomEditorOutlineTemplate { + const elementDisposables = new DisposableStore(); + container.classList.add('outline-element'); + const iconClass = dom.$('.outline-element-icon.inline'); + container.prepend(iconClass); + const iconLabel = new IconLabel(container, { supportHighlights: true }); + const actionMenu = dom.$('.action-menu'); + actionMenu.style.marginLeft = 'auto'; + container.appendChild(actionMenu); + return { container, iconClass, iconLabel, actionMenu, elementDisposables }; + } + + renderElement(node: ITreeNode, _index: number, template: ICustomEditorOutlineTemplate): void { + const entry = node.element; + const options: IIconLabelValueOptions = { + matches: createMatches(node.filterData), + labelEscapeNewLines: true, + }; + + template.iconClass.className = ''; + template.iconClass.classList.add('outline-element-icon', 'inline'); + if (entry.icon) { + template.iconClass.classList.add('codicon-colored', ...ThemeIcon.asClassNameArray(entry.icon)); + } + + template.iconLabel.setLabel(entry.label, entry.detail, options); + + if (this._target === OutlineTarget.OutlinePane) { + const scopedContextKeyService = template.elementDisposables.add(this._contextKeyService.createScoped(template.container)); + CustomEditorOutlineContext.ContextValue.bindTo(scopedContextKeyService).set(entry.contextValue ?? ''); + + const toolbar = template.elementDisposables.add(new ToolBar(template.actionMenu, this._contextMenuService, { + actionViewItemProvider: (action, options) => { + if (action instanceof MenuItemAction && !options.isMenu) { + return this._instantiationService.createInstance(MenuEntryActionViewItem, action, undefined); + } + return undefined; + }, + })); + + const menu = template.elementDisposables.add(this._menuService.createMenu(MenuId.CustomEditorOutlineActionMenu, scopedContextKeyService)); + const actions = getActionBarActions(menu.getActions({ shouldForwardArgs: true }), g => /^inline/.test(g)); + toolbar.setActions(actions.primary, actions.secondary); + + template.elementDisposables.add(menu.onDidChange(() => { + const actions = getActionBarActions(menu.getActions({ shouldForwardArgs: true }), g => /^inline/.test(g)); + toolbar.setActions(actions.primary, actions.secondary); + })); + } + } + + disposeTemplate(templateData: ICustomEditorOutlineTemplate): void { + templateData.iconLabel.dispose(); + templateData.elementDisposables.dispose(); + } + + disposeElement(_element: ITreeNode, _index: number, templateData: ICustomEditorOutlineTemplate): void { + templateData.elementDisposables.clear(); + dom.clearNode(templateData.actionMenu); + } +} + +// ----- List helpers ----- + +class CustomEditorOutlineVirtualDelegate implements IListVirtualDelegate { + getHeight(_element: CustomEditorOutlineEntry): number { + return 22; + } + getTemplateId(_element: CustomEditorOutlineEntry): string { + return 'CustomEditorOutlineRenderer'; + } +} + +class CustomEditorOutlineComparator implements IOutlineComparator { + compareByPosition(a: CustomEditorOutlineEntry, b: CustomEditorOutlineEntry): number { + return 0; // Extensions control item order + } + compareByType(a: CustomEditorOutlineEntry, b: CustomEditorOutlineEntry): number { + return 0; + } + compareByName(a: CustomEditorOutlineEntry, b: CustomEditorOutlineEntry): number { + return a.label.localeCompare(b.label); + } +} + +class CustomEditorOutlineAccessibility implements IListAccessibilityProvider { + getAriaLabel(element: CustomEditorOutlineEntry): string | null { + return element.label; + } + getWidgetAriaLabel(): string { + return localize('customEditorOutline', "Custom Editor Outline"); + } +} + +class CustomEditorOutlineNavigationLabelProvider implements IKeyboardNavigationLabelProvider { + getKeyboardNavigationLabel(element: CustomEditorOutlineEntry): { toString(): string | undefined } | undefined { + return element.label; + } +} + +// ----- IOutline implementation ----- + +class CustomEditorExtensionOutline implements IOutline { + + readonly outlineKind = 'customEditor'; + + private readonly _disposables = new DisposableStore(); + private readonly _onDidChange = this._disposables.add(new Emitter()); + readonly onDidChange: Event = this._onDidChange.event; + + private _entries: CustomEditorOutlineEntry[] = []; + private _activeEntry: CustomEditorOutlineEntry | undefined; + private _flatMap = new Map(); + private readonly _resource: URI; + private readonly _viewType: string; + + readonly config: IOutlineListConfig; + + get isEmpty(): boolean { + return this._entries.length === 0; + } + + get activeElement(): CustomEditorOutlineEntry | undefined { + return this._activeEntry; + } + + get uri(): URI | undefined { + return this._resource; + } + + constructor( + editorInput: CustomEditorInput, + target: OutlineTarget, + @ICustomEditorOutlineProviderService private readonly _providerService: ICustomEditorOutlineProviderService, + @IConfigurationService configurationService: IConfigurationService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + this._resource = editorInput.resource; + this._viewType = editorInput.viewType; + + const delegate = new CustomEditorOutlineVirtualDelegate(); + const renderers = [instantiationService.createInstance(CustomEditorOutlineRenderer, target)]; + const comparator = new CustomEditorOutlineComparator(); + + const treeDataSource: IDataSource = { + getChildren: parent => { + if (parent instanceof CustomEditorOutlineEntry) { + return parent.children; + } + return this._entries; + } + }; + + const initialCollapse = configurationService.getValue(OutlineConfigKeys.collapseItems); + const options = { + collapseByDefault: target === OutlineTarget.Breadcrumbs || (target === OutlineTarget.OutlinePane && initialCollapse === OutlineConfigCollapseItemsValues.Collapsed), + expandOnlyOnTwistieClick: true, + multipleSelectionSupport: false, + accessibilityProvider: new CustomEditorOutlineAccessibility(), + identityProvider: { getId: (e: CustomEditorOutlineEntry) => e.id }, + keyboardNavigationLabelProvider: new CustomEditorOutlineNavigationLabelProvider(), + }; + + const breadcrumbsDataSource: IBreadcrumbsDataSource = { + getBreadcrumbElements: () => { + const result: { element: CustomEditorOutlineEntry; label: string }[] = []; + let current = this._activeEntry; + while (current) { + result.unshift({ element: current, label: current.label }); + current = current.parent; + } + return result; + }, + }; + + this.config = { + breadcrumbsDataSource, + delegate, + renderers, + treeDataSource, + comparator, + options, + quickPickDataSource: { + getQuickPickElements: () => { + const result: { element: CustomEditorOutlineEntry; label: string; iconClasses?: string[]; ariaLabel?: string; description?: string }[] = []; + const walk = (entries: CustomEditorOutlineEntry[]) => { + for (const entry of entries) { + result.push({ + element: entry, + label: entry.label, + ariaLabel: entry.label, + description: entry.detail, + iconClasses: entry.icon ? ThemeIcon.asClassNameArray(entry.icon) : undefined, + }); + walk(entry.children); + } + }; + walk(this._entries); + return result; + }, + }, + contextMenuId: MenuId.CustomEditorOutlineContext, + getContextKeyOverlay: (entry: CustomEditorOutlineEntry) => { + return [['customEditorOutlineItem', entry.contextValue ?? '']]; + }, + }; + + // Listen for outline data changes from the extension provider + this._disposables.add(this._providerService.onDidChangeOutline(this._viewType)(() => { + this._loadItems(); + })); + + // Listen for active item changes from the extension provider + this._disposables.add(this._providerService.onDidChangeActiveItem(this._viewType)(itemId => { + this._activeEntry = itemId ? this._flatMap.get(itemId) : undefined; + this._onDidChange.fire({ affectOnlyActiveElement: true }); + })); + + // Initial load + this._loadItems(); + } + + private async _loadItems(): Promise { + const cts = new CancellationTokenSource(); + try { + const dtos = await this._providerService.provideOutline(this._viewType, cts.token); + if (cts.token.isCancellationRequested) { + return; + } + this._flatMap.clear(); + this._entries = dtos ? this._convertItems(dtos, undefined) : []; + + // Restore active entry from the provider's cached active item + const activeId = this._providerService.getActiveItemId(this._viewType); + this._activeEntry = activeId ? this._flatMap.get(activeId) : undefined; + + this._onDidChange.fire({}); + } finally { + cts.dispose(); + } + } + + private _convertItems(dtos: ICustomEditorOutlineItemDto[], parent: CustomEditorOutlineEntry | undefined): CustomEditorOutlineEntry[] { + return dtos.map(dto => { + const entry = new CustomEditorOutlineEntry( + dto.id, + dto.label, + dto.detail, + dto.tooltip, + dto.icon, + dto.contextValue, + parent, + ); + this._flatMap.set(dto.id, entry); + if (dto.children) { + entry.children.push(...this._convertItems(dto.children, entry)); + } + return entry; + }); + } + + reveal(entry: CustomEditorOutlineEntry, _options: IEditorOptions, _sideBySide: boolean, _select: boolean): void { + this._providerService.revealItem(this._viewType, entry.id); + } + + preview(_entry: CustomEditorOutlineEntry): IDisposable { + return toDisposable(() => { }); + } + + captureViewState(): IDisposable { + return toDisposable(() => { }); + } + + dispose(): void { + this._disposables.dispose(); + this._onDidChange.dispose(); + } +} + +// ----- Outline creator ----- + +class CustomEditorOutlineCreator extends Disposable implements IOutlineCreator { + + private readonly _registration: MutableDisposable; + + constructor( + @IOutlineService private readonly _outlineService: IOutlineService, + @ICustomEditorOutlineProviderService private readonly _providerService: ICustomEditorOutlineProviderService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + ) { + super(); + this._registration = this._register(new MutableDisposable()); + this._registration.value = this._outlineService.registerOutlineCreator(this); + + // When providers are added/removed, re-register so IOutlineService.onDidChange + // fires and the outline pane re-evaluates canCreateOutline. + this._register(this._providerService.onDidChange(() => { + this._registration.value = this._outlineService.registerOutlineCreator(this); + })); + } + + matches(candidate: IEditorPane): candidate is IEditorPane { + if (!(candidate.input instanceof CustomEditorInput)) { + return false; + } + return this._providerService.hasProvider(candidate.input.viewType); + } + + async createOutline(pane: IEditorPane, target: OutlineTarget, _token: CancellationToken): Promise | undefined> { + const input = pane.input; + if (!(input instanceof CustomEditorInput)) { + return undefined; + } + if (!this._providerService.hasProvider(input.viewType)) { + return undefined; + } + return this._instantiationService.createInstance(CustomEditorExtensionOutline, input, target); + } +} + +Registry.as(WorkbenchExtensions.Workbench).registerWorkbenchContribution(CustomEditorOutlineCreator, LifecyclePhase.Eventually); diff --git a/src/vs/workbench/contrib/customEditor/common/customEditorOutlineService.ts b/src/vs/workbench/contrib/customEditor/common/customEditorOutlineService.ts new file mode 100644 index 00000000000000..8085fc00a90ce7 --- /dev/null +++ b/src/vs/workbench/contrib/customEditor/common/customEditorOutlineService.ts @@ -0,0 +1,42 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Event } from '../../../../base/common/event.js'; +import { IDisposable } from '../../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; + +export const ICustomEditorOutlineProviderService = createDecorator('customEditorOutlineProviderService'); + +export interface ICustomEditorOutlineItemDto { + readonly id: string; + readonly label: string; + readonly detail?: string; + readonly tooltip?: string; + readonly icon?: ThemeIcon; + readonly contextValue?: string; + readonly children?: ICustomEditorOutlineItemDto[]; +} + +export interface ICustomEditorOutlineProviderService { + readonly _serviceBrand: undefined; + + readonly onDidChange: Event; + + hasProvider(viewType: string): boolean; + getProviderViewTypes(): string[]; + provideOutline(viewType: string, token: CancellationToken): Promise; + revealItem(viewType: string, itemId: string): void; + getActiveItemId(viewType: string): string | undefined; + + onDidChangeOutline(viewType: string): Event; + onDidChangeActiveItem(viewType: string): Event; + + registerProvider(viewType: string): IDisposable; + unregisterProvider(viewType: string): void; + fireDidChangeOutline(viewType: string): void; + fireDidChangeActiveItem(viewType: string, itemId: string | undefined): void; +} diff --git a/src/vs/workbench/contrib/outline/browser/outlinePane.ts b/src/vs/workbench/contrib/outline/browser/outlinePane.ts index e59e5bd2d7ff2c..89bd43a5397130 100644 --- a/src/vs/workbench/contrib/outline/browser/outlinePane.ts +++ b/src/vs/workbench/contrib/outline/browser/outlinePane.ts @@ -273,6 +273,30 @@ export class OutlinePane extends ViewPane implements IOutlinePane { ctxFocused.bindTo(tree.contextKeyService); + // feature: right-click context menu via outline config + if (newOutline.config.contextMenuId) { + this._editorControlDisposables.add(tree.onContextMenu(e => { + if (!e.element) { + return; + } + e.browserEvent.preventDefault(); + e.browserEvent.stopPropagation(); + tree.setFocus([e.element]); + + // Build a context key overlay with per-element values so that + // menu `when` clauses (e.g. customEditorOutlineItem == 'x') resolve correctly. + const overlay = newOutline.config.getContextKeyOverlay?.(e.element) ?? []; + const contextKeyService = tree.contextKeyService.createOverlay(overlay); + + this.contextMenuService.showContextMenu({ + menuId: newOutline.config.contextMenuId, + menuActionOptions: { shouldForwardArgs: true }, + contextKeyService, + getAnchor: () => e.anchor, + }); + })); + } + // update tree, listen to changes const updateTree = () => { if (newOutline.isEmpty) { diff --git a/src/vs/workbench/services/actions/common/menusExtensionPoint.ts b/src/vs/workbench/services/actions/common/menusExtensionPoint.ts index 67c86483f14b8e..fb1f644ac96db1 100644 --- a/src/vs/workbench/services/actions/common/menusExtensionPoint.ts +++ b/src/vs/workbench/services/actions/common/menusExtensionPoint.ts @@ -336,6 +336,18 @@ const apiMenus: IAPIMenu[] = [ id: MenuId.InteractiveCellTitle, description: localize('interactive.cell.title', "The contributed interactive cell title menu"), }, + { + key: 'customEditor/outline/toolbar', + id: MenuId.CustomEditorOutlineActionMenu, + description: localize('customEditor.outline.toolbar', "The contributed custom editor outline toolbar actions"), + proposed: 'customEditorOutline', + }, + { + key: 'customEditor/outline/context', + id: MenuId.CustomEditorOutlineContext, + description: localize('customEditor.outline.context', "The contributed custom editor outline context menu"), + proposed: 'customEditorOutline', + }, { key: 'issue/reporter', id: MenuId.IssueReporter, diff --git a/src/vs/workbench/services/outline/browser/outline.ts b/src/vs/workbench/services/outline/browser/outline.ts index d61aaf7aa7cd64..a57d8268435efc 100644 --- a/src/vs/workbench/services/outline/browser/outline.ts +++ b/src/vs/workbench/services/outline/browser/outline.ts @@ -10,6 +10,7 @@ import { Event } from '../../../../base/common/event.js'; import { FuzzyScore } from '../../../../base/common/filters.js'; import { IDisposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; +import { MenuId } from '../../../../platform/actions/common/actions.js'; import { IEditorOptions } from '../../../../platform/editor/common/editor.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { IWorkbenchDataTreeOptions } from '../../../../platform/list/browser/listService.js'; @@ -71,6 +72,8 @@ export interface IOutlineListConfig { readonly comparator: IOutlineComparator; readonly options: IWorkbenchDataTreeOptions; readonly quickPickDataSource: IQuickPickDataSource; + readonly contextMenuId?: MenuId; + readonly getContextKeyOverlay?: (element: E) => [string, unknown][]; } export interface OutlineChangeEvent { diff --git a/src/vscode-dts/vscode.proposed.customEditorOutline.d.ts b/src/vscode-dts/vscode.proposed.customEditorOutline.d.ts new file mode 100644 index 00000000000000..38ff7e7b1a0d0b --- /dev/null +++ b/src/vscode-dts/vscode.proposed.customEditorOutline.d.ts @@ -0,0 +1,124 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + + // https://github.com/microsoft/vscode/issues/97095 + + /** + * An item in a custom editor outline tree. + */ + export interface CustomEditorOutlineItem { + + /** + * A unique identifier for this item. Used to track active elements + * and for reveal requests. + */ + readonly id: string; + + /** + * The label of this item displayed in the outline. + */ + readonly label: string; + + /** + * An optional detail string displayed after the label. + */ + readonly detail?: string; + + /** + * An optional tooltip shown on hover. + */ + readonly tooltip?: string; + + /** + * An optional icon for this item, using a ThemeIcon such as + * `new ThemeIcon('symbol-class')`. + */ + readonly icon?: ThemeIcon; + + /** + * An optional context value that can be used for contributing actions + * via `menus` in package.json using the `customEditorOutlineItem` context key. + * + * Example package.json contribution for toolbar and context menu: + * ```json + * "menus": { + * "customEditor/outline/toolbar": [{ + * "command": "myExt.deleteItem", + * "group": "inline", + * "when": "customEditorOutlineItem == 'myNodeType'" + * }], + * "customEditor/outline/context": [{ + * "command": "myExt.renameItem", + * "when": "customEditorOutlineItem == 'myNodeType'" + * }] + * } + * ``` + */ + readonly contextValue?: string; + + /** + * Child items of this item. + */ + readonly children?: CustomEditorOutlineItem[]; + } + + /** + * A provider that supplies outline data for custom editors. Register via + * {@link window.registerCustomEditorOutlineProvider}. + */ + export interface CustomEditorOutlineProvider { + + /** + * Fired when the outline data has changed and needs to be refreshed. + */ + readonly onDidChangeOutline: Event; + + /** + * Fired when the active (focused/selected) item in the custom editor + * has changed. The outline view will follow the active item and + * highlight it in the tree. + * + * Pass `undefined` if no item is currently active. + */ + readonly onDidChangeActiveItem: Event; + + /** + * Provide the outline items for the custom editor. + * + * @param token A cancellation token. + * @returns The root-level outline items, or `undefined` if no outline + * can be provided. + */ + provideOutline(token: CancellationToken): ProviderResult; + + /** + * Called when the user clicks an outline item. The extension should + * reveal/scroll to the corresponding element in the custom editor. + * + * @param itemId The {@link CustomEditorOutlineItem.id id} of the item to reveal. + */ + // eslint-disable-next-line local/vscode-dts-provider-naming + revealItem(itemId: string): void; + } + + export namespace window { + + /** + * Register an outline provider for a custom editor view type. + * + * When a custom editor of the given `viewType` is active, the outline + * view will be populated using the data from this provider instead of + * the default document symbol provider. + * + * @param viewType The view type of the custom editor as declared in + * `package.json` `customEditors`. + * @param provider The outline provider. + * @returns A disposable that unregisters this provider. + */ + export function registerCustomEditorOutlineProvider(viewType: string, provider: CustomEditorOutlineProvider): Disposable; + } +} From d7756eadb24ffc0ca1b030605304475bb76c3057 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Wed, 25 Mar 2026 23:05:02 +0100 Subject: [PATCH 2/6] fix copilot issues --- .../browser/mainThreadCustomEditorOutline.ts | 3 ++- .../browser/customEditorOutline.ts | 22 +++++++++++++++---- .../contrib/outline/browser/outlinePane.ts | 1 + 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/vs/workbench/api/browser/mainThreadCustomEditorOutline.ts b/src/vs/workbench/api/browser/mainThreadCustomEditorOutline.ts index f91e95a7573de1..30f3ee0f3fbb32 100644 --- a/src/vs/workbench/api/browser/mainThreadCustomEditorOutline.ts +++ b/src/vs/workbench/api/browser/mainThreadCustomEditorOutline.ts @@ -145,8 +145,9 @@ export class MainThreadCustomEditorOutline extends Disposable implements MainThr } $unregisterCustomEditorOutlineProvider(viewType: string): void { + // deleteAndDispose disposes the registration returned by registerProvider(), + // whose dispose handler already removes the entry and fires onDidChange. this._registrations.deleteAndDispose(viewType); - this._service.unregisterProvider(viewType); } $onDidChangeOutline(viewType: string): void { diff --git a/src/vs/workbench/contrib/customEditor/browser/customEditorOutline.ts b/src/vs/workbench/contrib/customEditor/browser/customEditorOutline.ts index fcf2685468ecd6..a14e24fbf429e3 100644 --- a/src/vs/workbench/contrib/customEditor/browser/customEditorOutline.ts +++ b/src/vs/workbench/contrib/customEditor/browser/customEditorOutline.ts @@ -75,6 +75,7 @@ class CustomEditorOutlineRenderer implements ITreeRenderer(OutlineConfigKeys.icons)) { template.iconClass.classList.add('codicon-colored', ...ThemeIcon.asClassNameArray(entry.icon)); } + if (entry.tooltip) { + options.title = entry.tooltip; + } template.iconLabel.setLabel(entry.label, entry.detail, options); if (this._target === OutlineTarget.OutlinePane) { @@ -118,11 +122,12 @@ class CustomEditorOutlineRenderer implements ITreeRenderer /^inline/.test(g)); + const menuArg = { id: entry.id }; + const actions = getActionBarActions(menu.getActions({ shouldForwardArgs: true, arg: menuArg }), g => /^inline/.test(g)); toolbar.setActions(actions.primary, actions.secondary); template.elementDisposables.add(menu.onDidChange(() => { - const actions = getActionBarActions(menu.getActions({ shouldForwardArgs: true }), g => /^inline/.test(g)); + const actions = getActionBarActions(menu.getActions({ shouldForwardArgs: true, arg: menuArg }), g => /^inline/.test(g)); toolbar.setActions(actions.primary, actions.secondary); })); } @@ -190,6 +195,7 @@ class CustomEditorExtensionOutline implements IOutline private _entries: CustomEditorOutlineEntry[] = []; private _activeEntry: CustomEditorOutlineEntry | undefined; private _flatMap = new Map(); + private _loadCts: CancellationTokenSource | undefined; private readonly _resource: URI; private readonly _viewType: string; @@ -300,7 +306,10 @@ class CustomEditorExtensionOutline implements IOutline } private async _loadItems(): Promise { - const cts = new CancellationTokenSource(); + // Cancel any previous in-flight request to avoid stale responses overwriting newer data + this._loadCts?.cancel(); + this._loadCts?.dispose(); + const cts = this._loadCts = new CancellationTokenSource(); try { const dtos = await this._providerService.provideOutline(this._viewType, cts.token); if (cts.token.isCancellationRequested) { @@ -315,6 +324,9 @@ class CustomEditorExtensionOutline implements IOutline this._onDidChange.fire({}); } finally { + if (this._loadCts === cts) { + this._loadCts = undefined; + } cts.dispose(); } } @@ -351,6 +363,8 @@ class CustomEditorExtensionOutline implements IOutline } dispose(): void { + this._loadCts?.cancel(); + this._loadCts?.dispose(); this._disposables.dispose(); this._onDidChange.dispose(); } diff --git a/src/vs/workbench/contrib/outline/browser/outlinePane.ts b/src/vs/workbench/contrib/outline/browser/outlinePane.ts index 89bd43a5397130..3ecf7838cae1fb 100644 --- a/src/vs/workbench/contrib/outline/browser/outlinePane.ts +++ b/src/vs/workbench/contrib/outline/browser/outlinePane.ts @@ -293,6 +293,7 @@ export class OutlinePane extends ViewPane implements IOutlinePane { menuActionOptions: { shouldForwardArgs: true }, contextKeyService, getAnchor: () => e.anchor, + getActionsContext: () => e.element, }); })); } From c5a785203b4fdad65b4fb783c9ae50675ab22609 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Wed, 25 Mar 2026 23:30:52 +0100 Subject: [PATCH 3/6] work on github issues --- .../contrib/customEditor/browser/customEditorOutline.ts | 3 +++ src/vs/workbench/contrib/outline/browser/outlinePane.ts | 2 +- src/vs/workbench/services/outline/browser/outline.ts | 4 +++- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/customEditor/browser/customEditorOutline.ts b/src/vs/workbench/contrib/customEditor/browser/customEditorOutline.ts index a14e24fbf429e3..390afe2a942192 100644 --- a/src/vs/workbench/contrib/customEditor/browser/customEditorOutline.ts +++ b/src/vs/workbench/contrib/customEditor/browser/customEditorOutline.ts @@ -288,6 +288,9 @@ class CustomEditorExtensionOutline implements IOutline getContextKeyOverlay: (entry: CustomEditorOutlineEntry) => { return [['customEditorOutlineItem', entry.contextValue ?? '']]; }, + getActionsContext: (entry: CustomEditorOutlineEntry) => { + return { id: entry.id }; + }, }; // Listen for outline data changes from the extension provider diff --git a/src/vs/workbench/contrib/outline/browser/outlinePane.ts b/src/vs/workbench/contrib/outline/browser/outlinePane.ts index 3ecf7838cae1fb..d194f48892f354 100644 --- a/src/vs/workbench/contrib/outline/browser/outlinePane.ts +++ b/src/vs/workbench/contrib/outline/browser/outlinePane.ts @@ -293,7 +293,7 @@ export class OutlinePane extends ViewPane implements IOutlinePane { menuActionOptions: { shouldForwardArgs: true }, contextKeyService, getAnchor: () => e.anchor, - getActionsContext: () => e.element, + getActionsContext: () => newOutline.config.getActionsContext?.(e.element) ?? e.element, }); })); } diff --git a/src/vs/workbench/services/outline/browser/outline.ts b/src/vs/workbench/services/outline/browser/outline.ts index a57d8268435efc..170d68f749281a 100644 --- a/src/vs/workbench/services/outline/browser/outline.ts +++ b/src/vs/workbench/services/outline/browser/outline.ts @@ -11,6 +11,7 @@ import { FuzzyScore } from '../../../../base/common/filters.js'; import { IDisposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { MenuId } from '../../../../platform/actions/common/actions.js'; +import { ContextKeyValue } from '../../../../platform/contextkey/common/contextkey.js'; import { IEditorOptions } from '../../../../platform/editor/common/editor.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { IWorkbenchDataTreeOptions } from '../../../../platform/list/browser/listService.js'; @@ -73,7 +74,8 @@ export interface IOutlineListConfig { readonly options: IWorkbenchDataTreeOptions; readonly quickPickDataSource: IQuickPickDataSource; readonly contextMenuId?: MenuId; - readonly getContextKeyOverlay?: (element: E) => [string, unknown][]; + readonly getContextKeyOverlay?: (element: E) => [string, ContextKeyValue][]; + readonly getActionsContext?: (element: E) => unknown; } export interface OutlineChangeEvent { From 210675f9359a0b231b35e4051e351db19dd6f026 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Thu, 26 Mar 2026 00:18:53 +0100 Subject: [PATCH 4/6] fix selection --- .../contrib/customEditor/browser/customEditorOutline.ts | 1 + src/vs/workbench/contrib/outline/browser/outlinePane.ts | 2 +- src/vs/workbench/services/outline/browser/outline.ts | 7 +++++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/customEditor/browser/customEditorOutline.ts b/src/vs/workbench/contrib/customEditor/browser/customEditorOutline.ts index 390afe2a942192..5cf6fa0f2980c6 100644 --- a/src/vs/workbench/contrib/customEditor/browser/customEditorOutline.ts +++ b/src/vs/workbench/contrib/customEditor/browser/customEditorOutline.ts @@ -291,6 +291,7 @@ class CustomEditorExtensionOutline implements IOutline getActionsContext: (entry: CustomEditorOutlineEntry) => { return { id: entry.id }; }, + alwaysRevealActiveElement: true, }; // Listen for outline data changes from the extension provider diff --git a/src/vs/workbench/contrib/outline/browser/outlinePane.ts b/src/vs/workbench/contrib/outline/browser/outlinePane.ts index d194f48892f354..7f4de3816e1c9a 100644 --- a/src/vs/workbench/contrib/outline/browser/outlinePane.ts +++ b/src/vs/workbench/contrib/outline/browser/outlinePane.ts @@ -348,7 +348,7 @@ export class OutlinePane extends ViewPane implements IOutlinePane { })); // feature: reveal editor selection in outline const revealActiveElement = () => { - if (!this._outlineViewState.followCursor || !newOutline.activeElement) { + if (!(this._outlineViewState.followCursor || newOutline.config.alwaysRevealActiveElement) || !newOutline.activeElement) { return; } let item = newOutline.activeElement; diff --git a/src/vs/workbench/services/outline/browser/outline.ts b/src/vs/workbench/services/outline/browser/outline.ts index 170d68f749281a..2a73e8f726940a 100644 --- a/src/vs/workbench/services/outline/browser/outline.ts +++ b/src/vs/workbench/services/outline/browser/outline.ts @@ -76,6 +76,13 @@ export interface IOutlineListConfig { readonly contextMenuId?: MenuId; readonly getContextKeyOverlay?: (element: E) => [string, ContextKeyValue][]; readonly getActionsContext?: (element: E) => unknown; + /** + * When true, the outline pane always reveals the active element regardless + * of the "follow cursor" setting. Useful for custom editors where the + * extension explicitly sets the active item rather than deriving it from + * cursor position. + */ + readonly alwaysRevealActiveElement?: boolean; } export interface OutlineChangeEvent { From e86d6c7cfa9ce0c4536452f4672f0fc7a655f607 Mon Sep 17 00:00:00 2001 From: jkuehner Date: Thu, 26 Mar 2026 11:56:25 +0100 Subject: [PATCH 5/6] fix comment https://github.com/microsoft/vscode/pull/304909#discussion_r2991489787 --- custom-editor-outline.md | 145 ++++++++++++------ .../browser/mainThreadCustomEditorOutline.ts | 85 +++++++--- .../workbench/api/common/extHost.protocol.ts | 8 +- .../api/common/extHostCustomEditorOutline.ts | 19 +-- .../browser/customEditorOutline.ts | 17 +- .../common/customEditorOutlineService.ts | 15 +- .../vscode.proposed.customEditorOutline.d.ts | 17 +- 7 files changed, 203 insertions(+), 103 deletions(-) diff --git a/custom-editor-outline.md b/custom-editor-outline.md index 13ff9927fa782b..ece03122bea2f0 100644 --- a/custom-editor-outline.md +++ b/custom-editor-outline.md @@ -8,6 +8,8 @@ A full proposed extension API was added so that extensions providing **Custom Editors** can also populate the **Outline view**, **Breadcrumbs**, and **Go to Symbol** quick-pick with their own tree of items — with support for active element tracking, reveal-on-click, context menus, and inline toolbar buttons. +All provider methods and events are scoped per document URI, so multiple editors of the same view type can be open simultaneously (e.g. split view) with independent outlines. + ### Architecture Overview ``` @@ -20,10 +22,11 @@ A full proposed extension API was added so that extensions providing **Custom Ed │ Your extension calls │ │ ▼ │ │ registerCustomEditor │ │ ICustomEditorOutline │ │ OutlineProvider(...) │ │ ProviderService │ -│ │ │ │ │ -└──────────────────────┘ │ ▼ │ +│ │ │ (routes by viewType+URI) │ +└──────────────────────┘ │ │ │ + │ ▼ │ │ CustomEditorExtensionOutline│ - │ (IOutline<> impl) │ + │ (IOutline<> per editor) │ │ │ │ │ ▼ │ │ Outline Pane / Breadcrumbs│ @@ -84,47 +87,70 @@ export function activate(context: vscode.ExtensionContext) { ### 3. Implement `CustomEditorOutlineProvider` +One provider is registered per `viewType`, but all methods receive a `Uri` so the provider can manage state for multiple open documents independently. + ```typescript class MyOutlineProvider implements vscode.CustomEditorOutlineProvider { - // --- Events --- + // --- Events (scoped per document URI) --- - private readonly _onDidChangeOutline = new vscode.EventEmitter(); + private readonly _onDidChangeOutline = new vscode.EventEmitter(); readonly onDidChangeOutline = this._onDidChangeOutline.event; - private readonly _onDidChangeActiveItem = new vscode.EventEmitter(); + private readonly _onDidChangeActiveItem = new vscode.EventEmitter<{ uri: vscode.Uri; itemId: string | undefined }>(); readonly onDidChangeActiveItem = this._onDidChangeActiveItem.event; - // --- State (updated from your webview) --- + // --- Per-document state --- - private _items: vscode.CustomEditorOutlineItem[] = []; + private readonly _state = new Map(); // --- Provider methods --- - provideOutline(token: vscode.CancellationToken): vscode.CustomEditorOutlineItem[] { - return this._items; + provideOutline(uri: vscode.Uri, token: vscode.CancellationToken): vscode.CustomEditorOutlineItem[] { + return this._state.get(uri.toString())?.items ?? []; } - revealItem(itemId: string): void { + revealItem(uri: vscode.Uri, itemId: string): void { // Called when the user clicks an outline node. - // Post a message to your webview to scroll to / select the element. - this._currentWebview?.postMessage({ + // Post a message to the correct webview to scroll to / select the element. + this._state.get(uri.toString())?.webview?.postMessage({ type: 'revealElement', id: itemId, }); } - // --- Methods you call from your code --- + // --- Methods you call from your editor provider --- + + /** Call from resolveCustomTextEditor to register a webview for a document */ + setWebview(uri: vscode.Uri, webview: vscode.Webview): void { + const existing = this._state.get(uri.toString()); + if (existing) { + existing.webview = webview; + } else { + this._state.set(uri.toString(), { items: [], webview }); + } + } + + /** Call when a document's editor is disposed */ + removeDocument(uri: vscode.Uri): void { + this._state.delete(uri.toString()); + } /** Call when the document structure changes */ - updateStructure(items: vscode.CustomEditorOutlineItem[]): void { - this._items = items; - this._onDidChangeOutline.fire(); // triggers a refresh in the Outline view + updateStructure(uri: vscode.Uri, items: vscode.CustomEditorOutlineItem[]): void { + const state = this._state.get(uri.toString()); + if (state) { + state.items = items; + } + this._onDidChangeOutline.fire(uri); // triggers a refresh for this document's outline } /** Call when the user selects/focuses a different element in the webview */ - setActiveElement(itemId: string | undefined): void { - this._onDidChangeActiveItem.fire(itemId); // highlights the item in the Outline + setActiveElement(uri: vscode.Uri, itemId: string | undefined): void { + this._onDidChangeActiveItem.fire({ uri, itemId }); // highlights the item in the Outline } } ``` @@ -172,20 +198,29 @@ const items: vscode.CustomEditorOutlineItem[] = [ ### 5. Connect Your Webview -Your webview should post messages when the structure changes or the user selects an element: +Pass the document URI when calling outline provider methods from your custom editor: ```typescript // In your CustomTextEditorProvider.resolveCustomTextEditor(): +const uri = document.uri; + +outlineProvider.setWebview(uri, webviewPanel.webview); + webviewPanel.webview.onDidReceiveMessage(message => { switch (message.type) { case 'structureChanged': - outlineProvider.updateStructure(message.items); + outlineProvider.updateStructure(uri, message.items); break; case 'selectionChanged': - outlineProvider.setActiveElement(message.elementId); + outlineProvider.setActiveElement(uri, message.elementId); break; } }); + +// Clean up when the editor is disposed +webviewPanel.onDidDispose(() => { + outlineProvider.removeDocument(uri); +}); ``` And your webview should listen for reveal requests: @@ -364,35 +399,52 @@ export function activate(context: vscode.ExtensionContext) { // ─── Outline Provider ────────────────────────────────────────── +interface DocumentState { + items: vscode.CustomEditorOutlineItem[]; + webview: vscode.Webview; +} + class DesignerOutlineProvider implements vscode.CustomEditorOutlineProvider { - private readonly _onDidChangeOutline = new vscode.EventEmitter(); + private readonly _onDidChangeOutline = new vscode.EventEmitter(); readonly onDidChangeOutline = this._onDidChangeOutline.event; - private readonly _onDidChangeActiveItem = new vscode.EventEmitter(); + private readonly _onDidChangeActiveItem = new vscode.EventEmitter<{ uri: vscode.Uri; itemId: string | undefined }>(); readonly onDidChangeActiveItem = this._onDidChangeActiveItem.event; - private _items: vscode.CustomEditorOutlineItem[] = []; - private _webview: vscode.Webview | undefined; + private readonly _documents = new Map(); - setWebview(webview: vscode.Webview): void { - this._webview = webview; + setWebview(uri: vscode.Uri, webview: vscode.Webview): void { + const key = uri.toString(); + const existing = this._documents.get(key); + if (existing) { + existing.webview = webview; + } else { + this._documents.set(key, { items: [], webview }); + } } - updateItems(items: vscode.CustomEditorOutlineItem[]): void { - this._items = items; - this._onDidChangeOutline.fire(); + removeDocument(uri: vscode.Uri): void { + this._documents.delete(uri.toString()); } - setActive(itemId: string | undefined): void { - this._onDidChangeActiveItem.fire(itemId); + updateItems(uri: vscode.Uri, items: vscode.CustomEditorOutlineItem[]): void { + const state = this._documents.get(uri.toString()); + if (state) { + state.items = items; + } + this._onDidChangeOutline.fire(uri); + } + + setActive(uri: vscode.Uri, itemId: string | undefined): void { + this._onDidChangeActiveItem.fire({ uri, itemId }); } - provideOutline(_token: vscode.CancellationToken): vscode.CustomEditorOutlineItem[] { - return this._items; + provideOutline(uri: vscode.Uri, _token: vscode.CancellationToken): vscode.CustomEditorOutlineItem[] { + return this._documents.get(uri.toString())?.items ?? []; } - revealItem(itemId: string): void { - this._webview?.postMessage({ type: 'reveal', id: itemId }); + revealItem(uri: vscode.Uri, itemId: string): void { + this._documents.get(uri.toString())?.webview?.postMessage({ type: 'reveal', id: itemId }); } } @@ -408,11 +460,12 @@ class DesignerEditorProvider implements vscode.CustomTextEditorProvider { document: vscode.TextDocument, webviewPanel: vscode.WebviewPanel, ): Promise { + const uri = document.uri; webviewPanel.webview.options = { enableScripts: true }; - this.outline.setWebview(webviewPanel.webview); + this.outline.setWebview(uri, webviewPanel.webview); // Provide some initial outline items - this.outline.updateItems([ + this.outline.updateItems(uri, [ { id: 'root', label: 'Canvas', @@ -438,10 +491,15 @@ class DesignerEditorProvider implements vscode.CustomTextEditorProvider { // Listen for webview messages webviewPanel.webview.onDidReceiveMessage(msg => { if (msg.type === 'selectionChanged') { - this.outline.setActive(msg.id); + this.outline.setActive(uri, msg.id); } }); + // Clean up when the editor is disposed + webviewPanel.onDidDispose(() => { + this.outline.removeDocument(uri); + }); + webviewPanel.webview.html = `

Visual Designer

@@ -465,9 +523,10 @@ class DesignerEditorProvider implements vscode.CustomTextEditorProvider { | Feature | How It Works | |---------|-------------| -| **Outline tree** | `provideOutline()` returns your items → they appear in the Outline view | -| **Active element tracking** | Fire `onDidChangeActiveItem` with an item `id` → that node gets highlighted | -| **Reveal on click** | User clicks outline node → `revealItem(id)` is called → you scroll the webview | +| **Outline tree** | `provideOutline(uri)` returns your items → they appear in the Outline view | +| **Multi-editor support** | Each method receives a `Uri`, so multiple editors of the same type get independent outlines | +| **Active element tracking** | Fire `onDidChangeActiveItem` with `{ uri, itemId }` → that node gets highlighted | +| **Reveal on click** | User clicks outline node → `revealItem(uri, id)` is called → you scroll the correct webview | | **Breadcrumbs** | Automatically built from the active item's parent chain | | **Go to Symbol** | Items appear in the Ctrl+Shift+O quick-pick | | **Inline buttons** | Contribute to `customEditor/outline/toolbar` with `"group": "inline"` | diff --git a/src/vs/workbench/api/browser/mainThreadCustomEditorOutline.ts b/src/vs/workbench/api/browser/mainThreadCustomEditorOutline.ts index 30f3ee0f3fbb32..316208af6c3029 100644 --- a/src/vs/workbench/api/browser/mainThreadCustomEditorOutline.ts +++ b/src/vs/workbench/api/browser/mainThreadCustomEditorOutline.ts @@ -6,12 +6,13 @@ import { CancellationToken } from '../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable, DisposableMap, IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { URI, UriComponents } from '../../../base/common/uri.js'; import { InstantiationType, registerSingleton } from '../../../platform/instantiation/common/extensions.js'; import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js'; import { ExtHostContext, ExtHostCustomEditorOutlineShape, MainContext, MainThreadCustomEditorOutlineShape } from '../common/extHost.protocol.js'; import { ICustomEditorOutlineItemDto, ICustomEditorOutlineProviderService } from '../../contrib/customEditor/common/customEditorOutlineService.js'; -class CustomEditorOutlineProviderEntry { +class ResourceEntry { private readonly _onDidChangeOutline = new Emitter(); readonly onDidChangeOutline = this._onDidChangeOutline.event; @@ -37,6 +38,40 @@ class CustomEditorOutlineProviderEntry { } } +class CustomEditorOutlineProviderEntry { + + private readonly _resourceEntries = new Map(); + + getOrCreateResourceEntry(resource: URI): ResourceEntry { + const key = resource.toString(); + let entry = this._resourceEntries.get(key); + if (!entry) { + entry = new ResourceEntry(); + this._resourceEntries.set(key, entry); + } + return entry; + } + + getResourceEntry(resource: URI): ResourceEntry | undefined { + return this._resourceEntries.get(resource.toString()); + } + + fireDidChangeOutline(resource: URI): void { + this._resourceEntries.get(resource.toString())?.fireDidChangeOutline(); + } + + fireDidChangeActiveItem(resource: URI, itemId: string | undefined): void { + this.getOrCreateResourceEntry(resource).fireDidChangeActiveItem(itemId); + } + + dispose(): void { + for (const entry of this._resourceEntries.values()) { + entry.dispose(); + } + this._resourceEntries.clear(); + } +} + class CustomEditorOutlineProviderService extends Disposable implements ICustomEditorOutlineProviderService { declare readonly _serviceBrand: undefined; @@ -45,12 +80,12 @@ class CustomEditorOutlineProviderService extends Disposable implements ICustomEd private readonly _onDidChange = this._register(new Emitter()); readonly onDidChange: Event = this._onDidChange.event; - private _provideOutline?: (viewType: string, token: CancellationToken) => Promise; - private _revealItem?: (viewType: string, itemId: string) => void; + private _provideOutline?: (viewType: string, resource: URI, token: CancellationToken) => Promise; + private _revealItem?: (viewType: string, resource: URI, itemId: string) => void; setDelegate(delegate: { - provideOutline: (viewType: string, token: CancellationToken) => Promise; - revealItem: (viewType: string, itemId: string) => void; + provideOutline: (viewType: string, resource: URI, token: CancellationToken) => Promise; + revealItem: (viewType: string, resource: URI, itemId: string) => void; }): void { this._provideOutline = delegate.provideOutline; this._revealItem = delegate.revealItem; @@ -64,31 +99,31 @@ class CustomEditorOutlineProviderService extends Disposable implements ICustomEd return [...this._entries.keys()]; } - async provideOutline(viewType: string, token: CancellationToken): Promise { + async provideOutline(viewType: string, resource: URI, token: CancellationToken): Promise { if (this._provideOutline) { - return this._provideOutline(viewType, token); + return this._provideOutline(viewType, resource, token); } return undefined; } - revealItem(viewType: string, itemId: string): void { + revealItem(viewType: string, resource: URI, itemId: string): void { if (this._revealItem) { - this._revealItem(viewType, itemId); + this._revealItem(viewType, resource, itemId); } } - getActiveItemId(viewType: string): string | undefined { - return this._entries.get(viewType)?.activeItemId; + getActiveItemId(viewType: string, resource: URI): string | undefined { + return this._entries.get(viewType)?.getResourceEntry(resource)?.activeItemId; } - onDidChangeOutline(viewType: string): Event { + onDidChangeOutline(viewType: string, resource: URI): Event { const entry = this._entries.get(viewType); - return entry ? entry.onDidChangeOutline : Event.None; + return entry ? entry.getOrCreateResourceEntry(resource).onDidChangeOutline : Event.None; } - onDidChangeActiveItem(viewType: string): Event { + onDidChangeActiveItem(viewType: string, resource: URI): Event { const entry = this._entries.get(viewType); - return entry ? entry.onDidChangeActiveItem : Event.None; + return entry ? entry.getOrCreateResourceEntry(resource).onDidChangeActiveItem : Event.None; } registerProvider(viewType: string): IDisposable { @@ -106,12 +141,12 @@ class CustomEditorOutlineProviderService extends Disposable implements ICustomEd this._onDidChange.fire(); } - fireDidChangeOutline(viewType: string): void { - this._entries.get(viewType)?.fireDidChangeOutline(); + fireDidChangeOutline(viewType: string, resource: URI): void { + this._entries.get(viewType)?.fireDidChangeOutline(resource); } - fireDidChangeActiveItem(viewType: string, itemId: string | undefined): void { - this._entries.get(viewType)?.fireDidChangeActiveItem(itemId); + fireDidChangeActiveItem(viewType: string, resource: URI, itemId: string | undefined): void { + this._entries.get(viewType)?.fireDidChangeActiveItem(resource, itemId); } } @@ -133,8 +168,8 @@ export class MainThreadCustomEditorOutline extends Disposable implements MainThr // Wire the service delegate to call through to the ext host if (this._service instanceof CustomEditorOutlineProviderService) { this._service.setDelegate({ - provideOutline: (viewType, token) => this._proxy.$provideOutline(viewType, token), - revealItem: (viewType, itemId) => this._proxy.$revealItem(viewType, itemId), + provideOutline: (viewType, resource, token) => this._proxy.$provideOutline(viewType, resource, token), + revealItem: (viewType, resource, itemId) => this._proxy.$revealItem(viewType, resource, itemId), }); } } @@ -150,11 +185,11 @@ export class MainThreadCustomEditorOutline extends Disposable implements MainThr this._registrations.deleteAndDispose(viewType); } - $onDidChangeOutline(viewType: string): void { - this._service.fireDidChangeOutline(viewType); + $onDidChangeOutline(viewType: string, resource: UriComponents): void { + this._service.fireDidChangeOutline(viewType, URI.revive(resource)); } - $onDidChangeActiveItem(viewType: string, itemId: string | undefined): void { - this._service.fireDidChangeActiveItem(viewType, itemId); + $onDidChangeActiveItem(viewType: string, resource: UriComponents, itemId: string | undefined): void { + this._service.fireDidChangeActiveItem(viewType, URI.revive(resource), itemId); } } diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 49ca168c915cd1..251ad427a36a03 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1174,13 +1174,13 @@ export type { ICustomEditorOutlineItemDto } from '../../contrib/customEditor/com export interface MainThreadCustomEditorOutlineShape extends IDisposable { $registerCustomEditorOutlineProvider(viewType: string): void; $unregisterCustomEditorOutlineProvider(viewType: string): void; - $onDidChangeOutline(viewType: string): void; - $onDidChangeActiveItem(viewType: string, itemId: string | undefined): void; + $onDidChangeOutline(viewType: string, resource: UriComponents): void; + $onDidChangeActiveItem(viewType: string, resource: UriComponents, itemId: string | undefined): void; } export interface ExtHostCustomEditorOutlineShape { - $provideOutline(viewType: string, token: CancellationToken): Promise; - $revealItem(viewType: string, itemId: string): void; + $provideOutline(viewType: string, resource: UriComponents, token: CancellationToken): Promise; + $revealItem(viewType: string, resource: UriComponents, itemId: string): void; } export interface ExtHostWebviewViewsShape { diff --git a/src/vs/workbench/api/common/extHostCustomEditorOutline.ts b/src/vs/workbench/api/common/extHostCustomEditorOutline.ts index efb04133d56759..ee858a2c9f15ed 100644 --- a/src/vs/workbench/api/common/extHostCustomEditorOutline.ts +++ b/src/vs/workbench/api/common/extHostCustomEditorOutline.ts @@ -6,10 +6,11 @@ import type * as vscode from 'vscode'; import { CancellationToken } from '../../../base/common/cancellation.js'; import { DisposableStore, toDisposable } from '../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../base/common/themables.js'; +import { URI, UriComponents } from '../../../base/common/uri.js'; import { ExtHostCustomEditorOutlineShape, ICustomEditorOutlineItemDto, MainContext, MainThreadCustomEditorOutlineShape } from './extHost.protocol.js'; import { IExtensionDescription } from '../../../platform/extensions/common/extensions.js'; import { checkProposedApiEnabled } from '../../services/extensions/common/extensions.js'; -import { ThemeIcon } from '../../../base/common/themables.js'; import { IRPCProtocol } from '../../services/extensions/common/proxyIdentifier.js'; export class ExtHostCustomEditorOutline implements ExtHostCustomEditorOutlineShape { @@ -39,12 +40,12 @@ export class ExtHostCustomEditorOutline implements ExtHostCustomEditorOutlineSha this._providers.set(viewType, { provider, disposables }); this._proxy.$registerCustomEditorOutlineProvider(viewType); - disposables.add(provider.onDidChangeOutline(() => { - this._proxy.$onDidChangeOutline(viewType); + disposables.add(provider.onDidChangeOutline(resource => { + this._proxy.$onDidChangeOutline(viewType, resource); })); - disposables.add(provider.onDidChangeActiveItem(itemId => { - this._proxy.$onDidChangeActiveItem(viewType, itemId); + disposables.add(provider.onDidChangeActiveItem(({ uri, itemId }) => { + this._proxy.$onDidChangeActiveItem(viewType, uri, itemId); })); return toDisposable(() => { @@ -54,22 +55,22 @@ export class ExtHostCustomEditorOutline implements ExtHostCustomEditorOutlineSha }); } - async $provideOutline(viewType: string, token: CancellationToken): Promise { + async $provideOutline(viewType: string, resource: UriComponents, token: CancellationToken): Promise { const entry = this._providers.get(viewType); if (!entry) { return undefined; } - const items = await entry.provider.provideOutline(token); + const items = await entry.provider.provideOutline(URI.revive(resource), token); if (!items) { return undefined; } return items.map(item => this._convertItem(item)); } - $revealItem(viewType: string, itemId: string): void { + $revealItem(viewType: string, resource: UriComponents, itemId: string): void { const entry = this._providers.get(viewType); if (entry) { - entry.provider.revealItem(itemId); + entry.provider.revealItem(URI.revive(resource), itemId); } } diff --git a/src/vs/workbench/contrib/customEditor/browser/customEditorOutline.ts b/src/vs/workbench/contrib/customEditor/browser/customEditorOutline.ts index 5cf6fa0f2980c6..482ca67c3782ad 100644 --- a/src/vs/workbench/contrib/customEditor/browser/customEditorOutline.ts +++ b/src/vs/workbench/contrib/customEditor/browser/customEditorOutline.ts @@ -71,6 +71,7 @@ class CustomEditorOutlineRenderer implements ITreeRenderer /^inline/.test(g)); toolbar.setActions(actions.primary, actions.secondary); @@ -224,7 +225,7 @@ class CustomEditorExtensionOutline implements IOutline this._viewType = editorInput.viewType; const delegate = new CustomEditorOutlineVirtualDelegate(); - const renderers = [instantiationService.createInstance(CustomEditorOutlineRenderer, target)]; + const renderers = [instantiationService.createInstance(CustomEditorOutlineRenderer, target, this._resource)]; const comparator = new CustomEditorOutlineComparator(); const treeDataSource: IDataSource = { @@ -289,18 +290,18 @@ class CustomEditorExtensionOutline implements IOutline return [['customEditorOutlineItem', entry.contextValue ?? '']]; }, getActionsContext: (entry: CustomEditorOutlineEntry) => { - return { id: entry.id }; + return { id: entry.id, uri: this._resource }; }, alwaysRevealActiveElement: true, }; // Listen for outline data changes from the extension provider - this._disposables.add(this._providerService.onDidChangeOutline(this._viewType)(() => { + this._disposables.add(this._providerService.onDidChangeOutline(this._viewType, this._resource)(() => { this._loadItems(); })); // Listen for active item changes from the extension provider - this._disposables.add(this._providerService.onDidChangeActiveItem(this._viewType)(itemId => { + this._disposables.add(this._providerService.onDidChangeActiveItem(this._viewType, this._resource)(itemId => { this._activeEntry = itemId ? this._flatMap.get(itemId) : undefined; this._onDidChange.fire({ affectOnlyActiveElement: true }); })); @@ -315,7 +316,7 @@ class CustomEditorExtensionOutline implements IOutline this._loadCts?.dispose(); const cts = this._loadCts = new CancellationTokenSource(); try { - const dtos = await this._providerService.provideOutline(this._viewType, cts.token); + const dtos = await this._providerService.provideOutline(this._viewType, this._resource, cts.token); if (cts.token.isCancellationRequested) { return; } @@ -323,7 +324,7 @@ class CustomEditorExtensionOutline implements IOutline this._entries = dtos ? this._convertItems(dtos, undefined) : []; // Restore active entry from the provider's cached active item - const activeId = this._providerService.getActiveItemId(this._viewType); + const activeId = this._providerService.getActiveItemId(this._viewType, this._resource); this._activeEntry = activeId ? this._flatMap.get(activeId) : undefined; this._onDidChange.fire({}); @@ -355,7 +356,7 @@ class CustomEditorExtensionOutline implements IOutline } reveal(entry: CustomEditorOutlineEntry, _options: IEditorOptions, _sideBySide: boolean, _select: boolean): void { - this._providerService.revealItem(this._viewType, entry.id); + this._providerService.revealItem(this._viewType, this._resource, entry.id); } preview(_entry: CustomEditorOutlineEntry): IDisposable { diff --git a/src/vs/workbench/contrib/customEditor/common/customEditorOutlineService.ts b/src/vs/workbench/contrib/customEditor/common/customEditorOutlineService.ts index 8085fc00a90ce7..eb7a93f2c757f1 100644 --- a/src/vs/workbench/contrib/customEditor/common/customEditorOutlineService.ts +++ b/src/vs/workbench/contrib/customEditor/common/customEditorOutlineService.ts @@ -7,6 +7,7 @@ import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Event } from '../../../../base/common/event.js'; import { IDisposable } from '../../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; +import { URI } from '../../../../base/common/uri.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; export const ICustomEditorOutlineProviderService = createDecorator('customEditorOutlineProviderService'); @@ -28,15 +29,15 @@ export interface ICustomEditorOutlineProviderService { hasProvider(viewType: string): boolean; getProviderViewTypes(): string[]; - provideOutline(viewType: string, token: CancellationToken): Promise; - revealItem(viewType: string, itemId: string): void; - getActiveItemId(viewType: string): string | undefined; + provideOutline(viewType: string, resource: URI, token: CancellationToken): Promise; + revealItem(viewType: string, resource: URI, itemId: string): void; + getActiveItemId(viewType: string, resource: URI): string | undefined; - onDidChangeOutline(viewType: string): Event; - onDidChangeActiveItem(viewType: string): Event; + onDidChangeOutline(viewType: string, resource: URI): Event; + onDidChangeActiveItem(viewType: string, resource: URI): Event; registerProvider(viewType: string): IDisposable; unregisterProvider(viewType: string): void; - fireDidChangeOutline(viewType: string): void; - fireDidChangeActiveItem(viewType: string, itemId: string | undefined): void; + fireDidChangeOutline(viewType: string, resource: URI): void; + fireDidChangeActiveItem(viewType: string, resource: URI, itemId: string | undefined): void; } diff --git a/src/vscode-dts/vscode.proposed.customEditorOutline.d.ts b/src/vscode-dts/vscode.proposed.customEditorOutline.d.ts index 38ff7e7b1a0d0b..4acb711d37b9dd 100644 --- a/src/vscode-dts/vscode.proposed.customEditorOutline.d.ts +++ b/src/vscode-dts/vscode.proposed.customEditorOutline.d.ts @@ -73,36 +73,39 @@ declare module 'vscode' { export interface CustomEditorOutlineProvider { /** - * Fired when the outline data has changed and needs to be refreshed. + * Fired when the outline data for a specific resource has changed and + * needs to be refreshed. */ - readonly onDidChangeOutline: Event; + readonly onDidChangeOutline: Event; /** * Fired when the active (focused/selected) item in the custom editor * has changed. The outline view will follow the active item and * highlight it in the tree. * - * Pass `undefined` if no item is currently active. + * Pass `undefined` for `itemId` if no item is currently active. */ - readonly onDidChangeActiveItem: Event; + readonly onDidChangeActiveItem: Event<{ uri: Uri; itemId: string | undefined }>; /** - * Provide the outline items for the custom editor. + * Provide the outline items for the custom editor displaying the given document. * + * @param uri The URI of the document being displayed. * @param token A cancellation token. * @returns The root-level outline items, or `undefined` if no outline * can be provided. */ - provideOutline(token: CancellationToken): ProviderResult; + provideOutline(uri: Uri, token: CancellationToken): ProviderResult; /** * Called when the user clicks an outline item. The extension should * reveal/scroll to the corresponding element in the custom editor. * + * @param uri The URI of the document being displayed. * @param itemId The {@link CustomEditorOutlineItem.id id} of the item to reveal. */ // eslint-disable-next-line local/vscode-dts-provider-naming - revealItem(itemId: string): void; + revealItem(uri: Uri, itemId: string): void; } export namespace window { From a09bc67809f975bbffe36376934586096fcfc5fe Mon Sep 17 00:00:00 2001 From: jkuehner Date: Thu, 26 Mar 2026 12:26:42 +0100 Subject: [PATCH 6/6] fixes after copilot review --- .../browser/mainThreadCustomEditorOutline.ts | 13 ++++++++++++ .../browser/customEditorOutline.ts | 21 +++++++++++++++++++ .../common/customEditorOutlineService.ts | 1 + 3 files changed, 35 insertions(+) diff --git a/src/vs/workbench/api/browser/mainThreadCustomEditorOutline.ts b/src/vs/workbench/api/browser/mainThreadCustomEditorOutline.ts index 316208af6c3029..4043f8bf350c26 100644 --- a/src/vs/workbench/api/browser/mainThreadCustomEditorOutline.ts +++ b/src/vs/workbench/api/browser/mainThreadCustomEditorOutline.ts @@ -64,6 +64,15 @@ class CustomEditorOutlineProviderEntry { this.getOrCreateResourceEntry(resource).fireDidChangeActiveItem(itemId); } + removeResourceEntry(resource: URI): void { + const key = resource.toString(); + const entry = this._resourceEntries.get(key); + if (entry) { + entry.dispose(); + this._resourceEntries.delete(key); + } + } + dispose(): void { for (const entry of this._resourceEntries.values()) { entry.dispose(); @@ -141,6 +150,10 @@ class CustomEditorOutlineProviderService extends Disposable implements ICustomEd this._onDidChange.fire(); } + releaseResource(viewType: string, resource: URI): void { + this._entries.get(viewType)?.removeResourceEntry(resource); + } + fireDidChangeOutline(viewType: string, resource: URI): void { this._entries.get(viewType)?.fireDidChangeOutline(resource); } diff --git a/src/vs/workbench/contrib/customEditor/browser/customEditorOutline.ts b/src/vs/workbench/contrib/customEditor/browser/customEditorOutline.ts index 482ca67c3782ad..007b53d6dc81bf 100644 --- a/src/vs/workbench/contrib/customEditor/browser/customEditorOutline.ts +++ b/src/vs/workbench/contrib/customEditor/browser/customEditorOutline.ts @@ -9,6 +9,7 @@ import { IIconLabelValueOptions, IconLabel } from '../../../../base/browser/ui/i import { IKeyboardNavigationLabelProvider, IListVirtualDelegate } from '../../../../base/browser/ui/list/list.js'; import { IListAccessibilityProvider } from '../../../../base/browser/ui/list/listWidget.js'; import { IDataSource, ITreeNode, ITreeRenderer } from '../../../../base/browser/ui/tree/tree.js'; +import { disposableTimeout } from '../../../../base/common/async.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { FuzzyScore, createMatches } from '../../../../base/common/filters.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; @@ -124,13 +125,32 @@ class CustomEditorOutlineRenderer implements ITreeRenderer void) | undefined; + const actions = getActionBarActions(menu.getActions({ shouldForwardArgs: true, arg: menuArg }), g => /^inline/.test(g)); toolbar.setActions(actions.primary, actions.secondary); template.elementDisposables.add(menu.onDidChange(() => { const actions = getActionBarActions(menu.getActions({ shouldForwardArgs: true, arg: menuArg }), g => /^inline/.test(g)); + if (dropdownIsVisible) { + deferredUpdate = () => toolbar.setActions(actions.primary, actions.secondary); + return; + } toolbar.setActions(actions.primary, actions.secondary); })); + + template.elementDisposables.add(toolbar.onDidChangeDropdownVisibility(visible => { + dropdownIsVisible = visible; + if (deferredUpdate && !visible) { + disposableTimeout(() => { + deferredUpdate?.(); + deferredUpdate = undefined; + }, 0, template.elementDisposables); + } + })); } } @@ -370,6 +390,7 @@ class CustomEditorExtensionOutline implements IOutline dispose(): void { this._loadCts?.cancel(); this._loadCts?.dispose(); + this._providerService.releaseResource(this._viewType, this._resource); this._disposables.dispose(); this._onDidChange.dispose(); } diff --git a/src/vs/workbench/contrib/customEditor/common/customEditorOutlineService.ts b/src/vs/workbench/contrib/customEditor/common/customEditorOutlineService.ts index eb7a93f2c757f1..cff6312a2d06b7 100644 --- a/src/vs/workbench/contrib/customEditor/common/customEditorOutlineService.ts +++ b/src/vs/workbench/contrib/customEditor/common/customEditorOutlineService.ts @@ -38,6 +38,7 @@ export interface ICustomEditorOutlineProviderService { registerProvider(viewType: string): IDisposable; unregisterProvider(viewType: string): void; + releaseResource(viewType: string, resource: URI): void; fireDidChangeOutline(viewType: string, resource: URI): void; fireDidChangeActiveItem(viewType: string, resource: URI, itemId: string | undefined): void; }