diff --git a/custom-editor-outline.md b/custom-editor-outline.md new file mode 100644 index 00000000000000..ece03122bea2f0 --- /dev/null +++ b/custom-editor-outline.md @@ -0,0 +1,535 @@ +# 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. + +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 + +``` +┌──────────────────────┐ ┌─────────────────────────────┐ +│ Extension Host │ │ Main Thread (renderer) │ +│ │ RPC │ │ +│ ExtHostCustomEditor │◄────────►│ MainThreadCustomEditor │ +│ Outline │ │ Outline │ +│ │ │ │ │ +│ Your extension calls │ │ ▼ │ +│ registerCustomEditor │ │ ICustomEditorOutline │ +│ OutlineProvider(...) │ │ ProviderService │ +│ │ │ (routes by viewType+URI) │ +└──────────────────────┘ │ │ │ + │ ▼ │ + │ CustomEditorExtensionOutline│ + │ (IOutline<> per editor) │ + │ │ │ + │ ▼ │ + │ 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` + +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 (scoped per document URI) --- + + private readonly _onDidChangeOutline = new vscode.EventEmitter(); + readonly onDidChangeOutline = this._onDidChangeOutline.event; + + private readonly _onDidChangeActiveItem = new vscode.EventEmitter<{ uri: vscode.Uri; itemId: string | undefined }>(); + readonly onDidChangeActiveItem = this._onDidChangeActiveItem.event; + + // --- Per-document state --- + + private readonly _state = new Map(); + + // --- Provider methods --- + + provideOutline(uri: vscode.Uri, token: vscode.CancellationToken): vscode.CustomEditorOutlineItem[] { + return this._state.get(uri.toString())?.items ?? []; + } + + revealItem(uri: vscode.Uri, itemId: string): void { + // Called when the user clicks an outline node. + // 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 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(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(uri: vscode.Uri, itemId: string | undefined): void { + this._onDidChangeActiveItem.fire({ uri, 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 + +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(uri, message.items); + break; + case 'selectionChanged': + 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: + +```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 ────────────────────────────────────────── + +interface DocumentState { + items: vscode.CustomEditorOutlineItem[]; + webview: vscode.Webview; +} + +class DesignerOutlineProvider implements vscode.CustomEditorOutlineProvider { + private readonly _onDidChangeOutline = new vscode.EventEmitter(); + readonly onDidChangeOutline = this._onDidChangeOutline.event; + + private readonly _onDidChangeActiveItem = new vscode.EventEmitter<{ uri: vscode.Uri; itemId: string | undefined }>(); + readonly onDidChangeActiveItem = this._onDidChangeActiveItem.event; + + private readonly _documents = new Map(); + + 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 }); + } + } + + removeDocument(uri: vscode.Uri): void { + this._documents.delete(uri.toString()); + } + + 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(uri: vscode.Uri, _token: vscode.CancellationToken): vscode.CustomEditorOutlineItem[] { + return this._documents.get(uri.toString())?.items ?? []; + } + + revealItem(uri: vscode.Uri, itemId: string): void { + this._documents.get(uri.toString())?.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 { + const uri = document.uri; + webviewPanel.webview.options = { enableScripts: true }; + this.outline.setWebview(uri, webviewPanel.webview); + + // Provide some initial outline items + this.outline.updateItems(uri, [ + { + 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(uri, msg.id); + } + }); + + // Clean up when the editor is disposed + webviewPanel.onDidDispose(() => { + this.outline.removeDocument(uri); + }); + + webviewPanel.webview.html = ` + +

Visual Designer

+

This is a placeholder for your visual designer webview.

+ + `; + } +} +``` + +--- + +## Summary of Features + +| Feature | How It Works | +|---------|-------------| +| **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"` | +| **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..4043f8bf350c26 --- /dev/null +++ b/src/vs/workbench/api/browser/mainThreadCustomEditorOutline.ts @@ -0,0 +1,208 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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 ResourceEntry { + 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 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); + } + + 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(); + } + this._resourceEntries.clear(); + } +} + +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, resource: URI, token: CancellationToken) => Promise; + private _revealItem?: (viewType: string, resource: URI, itemId: string) => void; + + setDelegate(delegate: { + 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; + } + + hasProvider(viewType: string): boolean { + return this._entries.has(viewType); + } + + getProviderViewTypes(): string[] { + return [...this._entries.keys()]; + } + + async provideOutline(viewType: string, resource: URI, token: CancellationToken): Promise { + if (this._provideOutline) { + return this._provideOutline(viewType, resource, token); + } + return undefined; + } + + revealItem(viewType: string, resource: URI, itemId: string): void { + if (this._revealItem) { + this._revealItem(viewType, resource, itemId); + } + } + + getActiveItemId(viewType: string, resource: URI): string | undefined { + return this._entries.get(viewType)?.getResourceEntry(resource)?.activeItemId; + } + + onDidChangeOutline(viewType: string, resource: URI): Event { + const entry = this._entries.get(viewType); + return entry ? entry.getOrCreateResourceEntry(resource).onDidChangeOutline : Event.None; + } + + onDidChangeActiveItem(viewType: string, resource: URI): Event { + const entry = this._entries.get(viewType); + return entry ? entry.getOrCreateResourceEntry(resource).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(); + } + + releaseResource(viewType: string, resource: URI): void { + this._entries.get(viewType)?.removeResourceEntry(resource); + } + + fireDidChangeOutline(viewType: string, resource: URI): void { + this._entries.get(viewType)?.fireDidChangeOutline(resource); + } + + fireDidChangeActiveItem(viewType: string, resource: URI, itemId: string | undefined): void { + this._entries.get(viewType)?.fireDidChangeActiveItem(resource, 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, resource, token) => this._proxy.$provideOutline(viewType, resource, token), + revealItem: (viewType, resource, itemId) => this._proxy.$revealItem(viewType, resource, itemId), + }); + } + } + + $registerCustomEditorOutlineProvider(viewType: string): void { + const registration = this._service.registerProvider(viewType); + this._registrations.set(viewType, registration); + } + + $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); + } + + $onDidChangeOutline(viewType: string, resource: UriComponents): void { + this._service.fireDidChangeOutline(viewType, URI.revive(resource)); + } + + $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.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..251ad427a36a03 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, resource: UriComponents): void; + $onDidChangeActiveItem(viewType: string, resource: UriComponents, itemId: string | undefined): void; +} + +export interface ExtHostCustomEditorOutlineShape { + $provideOutline(viewType: string, resource: UriComponents, token: CancellationToken): Promise; + $revealItem(viewType: string, resource: UriComponents, 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..ee858a2c9f15ed --- /dev/null +++ b/src/vs/workbench/api/common/extHostCustomEditorOutline.ts @@ -0,0 +1,88 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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 { 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(resource => { + this._proxy.$onDidChangeOutline(viewType, resource); + })); + + disposables.add(provider.onDidChangeActiveItem(({ uri, itemId }) => { + this._proxy.$onDidChangeActiveItem(viewType, uri, itemId); + })); + + return toDisposable(() => { + this._providers.delete(viewType); + disposables.dispose(); + this._proxy.$unregisterCustomEditorOutlineProvider(viewType); + }); + } + + 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(URI.revive(resource), token); + if (!items) { + return undefined; + } + return items.map(item => this._convertItem(item)); + } + + $revealItem(viewType: string, resource: UriComponents, itemId: string): void { + const entry = this._providers.get(viewType); + if (entry) { + entry.provider.revealItem(URI.revive(resource), 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..007b53d6dc81bf --- /dev/null +++ b/src/vs/workbench/contrib/customEditor/browser/customEditorOutline.ts @@ -0,0 +1,440 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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'; +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, + private readonly _resource: URI, + @IContextMenuService private readonly _contextMenuService: IContextMenuService, + @IContextKeyService private readonly _contextKeyService: IContextKeyService, + @IMenuService private readonly _menuService: IMenuService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + ) { } + + 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 && this._configurationService.getValue(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) { + 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 menuArg = { id: entry.id, uri: this._resource }; + + // same fix as in notebookOutline setupToolbarListeners re #103926 + let dropdownIsVisible = false; + let deferredUpdate: (() => 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); + } + })); + } + } + + 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 _loadCts: CancellationTokenSource | undefined; + 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, this._resource)]; + 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 ?? '']]; + }, + getActionsContext: (entry: CustomEditorOutlineEntry) => { + 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._resource)(() => { + this._loadItems(); + })); + + // Listen for active item changes from the extension provider + this._disposables.add(this._providerService.onDidChangeActiveItem(this._viewType, this._resource)(itemId => { + this._activeEntry = itemId ? this._flatMap.get(itemId) : undefined; + this._onDidChange.fire({ affectOnlyActiveElement: true }); + })); + + // Initial load + this._loadItems(); + } + + private async _loadItems(): Promise { + // 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, this._resource, 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._resource); + this._activeEntry = activeId ? this._flatMap.get(activeId) : undefined; + + this._onDidChange.fire({}); + } finally { + if (this._loadCts === cts) { + this._loadCts = undefined; + } + 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, this._resource, entry.id); + } + + preview(_entry: CustomEditorOutlineEntry): IDisposable { + return toDisposable(() => { }); + } + + captureViewState(): IDisposable { + return toDisposable(() => { }); + } + + dispose(): void { + this._loadCts?.cancel(); + this._loadCts?.dispose(); + this._providerService.releaseResource(this._viewType, this._resource); + 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..cff6312a2d06b7 --- /dev/null +++ b/src/vs/workbench/contrib/customEditor/common/customEditorOutlineService.ts @@ -0,0 +1,44 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { URI } from '../../../../base/common/uri.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, resource: URI, token: CancellationToken): Promise; + revealItem(viewType: string, resource: URI, itemId: string): void; + getActiveItemId(viewType: string, resource: URI): string | undefined; + + onDidChangeOutline(viewType: string, resource: URI): Event; + onDidChangeActiveItem(viewType: string, resource: URI): Event; + + 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; +} diff --git a/src/vs/workbench/contrib/outline/browser/outlinePane.ts b/src/vs/workbench/contrib/outline/browser/outlinePane.ts index e59e5bd2d7ff2c..7f4de3816e1c9a 100644 --- a/src/vs/workbench/contrib/outline/browser/outlinePane.ts +++ b/src/vs/workbench/contrib/outline/browser/outlinePane.ts @@ -273,6 +273,31 @@ 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, + getActionsContext: () => newOutline.config.getActionsContext?.(e.element) ?? e.element, + }); + })); + } + // update tree, listen to changes const updateTree = () => { if (newOutline.isEmpty) { @@ -323,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/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..2a73e8f726940a 100644 --- a/src/vs/workbench/services/outline/browser/outline.ts +++ b/src/vs/workbench/services/outline/browser/outline.ts @@ -10,6 +10,8 @@ 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 { 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'; @@ -71,6 +73,16 @@ export interface IOutlineListConfig { readonly comparator: IOutlineComparator; readonly options: IWorkbenchDataTreeOptions; readonly quickPickDataSource: IQuickPickDataSource; + 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 { 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..4acb711d37b9dd --- /dev/null +++ b/src/vscode-dts/vscode.proposed.customEditorOutline.d.ts @@ -0,0 +1,127 @@ +/*--------------------------------------------------------------------------------------------- + * 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 for a specific resource 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` for `itemId` if no item is currently active. + */ + readonly onDidChangeActiveItem: Event<{ uri: Uri; itemId: string | undefined }>; + + /** + * 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(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(uri: Uri, 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; + } +}