diff --git a/src/vs/base/common/event.ts b/src/vs/base/common/event.ts index 4dbb96ccdb7..d992d0abe8a 100644 --- a/src/vs/base/common/event.ts +++ b/src/vs/base/common/event.ts @@ -645,13 +645,13 @@ export class Emitter { } dispose() { - this._listeners?.clear(); - this._deliveryQueue?.clear(); - if (this._options?.onLastListenerRemove) { - this._options.onLastListenerRemove(); + if (!this._disposed) { + this._disposed = true; + this._listeners?.clear(); + this._deliveryQueue?.clear(); + this._options?.onLastListenerRemove?.(); + this._leakageMon?.dispose(); } - this._leakageMon?.dispose(); - this._disposed = true; } } diff --git a/src/vs/base/test/common/event.test.ts b/src/vs/base/test/common/event.test.ts index a0f55cd405c..3fea2b43b4e 100644 --- a/src/vs/base/test/common/event.test.ts +++ b/src/vs/base/test/common/event.test.ts @@ -894,4 +894,14 @@ suite('Event utils', () => { listener.dispose(); }); + test('dispose is reentrant', () => { + const emitter = new Emitter({ + onLastListenerRemove: () => { + emitter.dispose(); + } + }); + + const listener = emitter.event(() => undefined); + listener.dispose(); // should not crash + }); }); diff --git a/src/vs/vscode.proposed.d.ts b/src/vs/vscode.proposed.d.ts index 16b6b7e85fd..67845179a46 100644 --- a/src/vs/vscode.proposed.d.ts +++ b/src/vs/vscode.proposed.d.ts @@ -2150,6 +2150,44 @@ declare module 'vscode' { //#endregion + //#region @connor4312 - notebook messaging: https://github.com/microsoft/vscode/issues/123601 + + /** + * Renderer messaging is used to communicate with a single renderer. It's + * returned from {@link notebook.createRendererMessaging}. + */ + export interface NotebookRendererMessaging { + /** + * Events that fires when a message is received from a renderer. + */ + messageHandler?: (editor: NotebookEditor, message: TReceive) => void; + + /** + * Sends a message to the renderer. + * @param message Message to send + */ + postMessage(message: TSend): void; + + /** + * Disposes of the messaging instance and unsubscribes from messages. + */ + dispose(): void; + } + + export namespace notebook { + /** + * Creates a new messaging instance used to communicate with a specific + * renderer. The renderer only has access to messaging if `requiresMessaging` + * is set in its contribution. + * + * @see https://github.com/microsoft/vscode/issues/123601 + * @param rendererId The renderer ID to communicate with + */ + export function createRendererMessaging(rendererId: string): NotebookRendererMessaging; + } + + //#endregion + //#region @eamodio - timeline: https://github.com/microsoft/vscode/issues/84297 export class TimelineItem { diff --git a/src/vs/workbench/api/browser/extensionHost.contribution.ts b/src/vs/workbench/api/browser/extensionHost.contribution.ts index 524697c3426..427e98ba553 100644 --- a/src/vs/workbench/api/browser/extensionHost.contribution.ts +++ b/src/vs/workbench/api/browser/extensionHost.contribution.ts @@ -65,6 +65,7 @@ import './mainThreadComments'; import './mainThreadNotebook'; import './mainThreadNotebookKernels'; import './mainThreadNotebookDocumentsAndEditors'; +import './mainThreadNotebookRenderers'; import './mainThreadTask'; import './mainThreadLabelService'; import './mainThreadTunnelService'; diff --git a/src/vs/workbench/api/browser/mainThreadNotebookRenderers.ts b/src/vs/workbench/api/browser/mainThreadNotebookRenderers.ts new file mode 100644 index 00000000000..0883f85bc29 --- /dev/null +++ b/src/vs/workbench/api/browser/mainThreadNotebookRenderers.ts @@ -0,0 +1,29 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from 'vs/base/common/lifecycle'; +import { ExtHostContext, ExtHostNotebookRenderersShape, IExtHostContext, MainContext, MainThreadNotebookRenderersShape } from 'vs/workbench/api/common/extHost.protocol'; +import { extHostNamedCustomer } from 'vs/workbench/api/common/extHostCustomers'; +import { INotebookRendererMessagingService } from 'vs/workbench/contrib/notebook/common/notebookRendererMessagingService'; + +@extHostNamedCustomer(MainContext.MainThreadNotebookRenderers) +export class MainThreadNotebookRenderers extends Disposable implements MainThreadNotebookRenderersShape { + private readonly proxy: ExtHostNotebookRenderersShape; + + constructor( + extHostContext: IExtHostContext, + @INotebookRendererMessagingService private readonly messaging: INotebookRendererMessagingService, + ) { + super(); + this.proxy = extHostContext.getProxy(ExtHostContext.ExtHostNotebookRenderers); + this._register(messaging.onShouldPostMessage(e => { + this.proxy.$postRendererMessage(e.editorId, e.rendererId, e.message); + })); + } + + $postMessage(rendererId: string, message: unknown): void { + this.messaging.fireDidReceiveMessage(rendererId, message); + } +} diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 71b0809eb0f..5a605ced71d 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -87,6 +87,7 @@ import { IExtHostTelemetry } from 'vs/workbench/api/common/extHostTelemetry'; import { ExtHostNotebookKernels } from 'vs/workbench/api/common/extHostNotebookKernels'; import { RemoteTrustOption } from 'vs/platform/remote/common/remoteAuthorityResolver'; import { TextSearchCompleteMessageType } from 'vs/workbench/services/search/common/searchExtTypes'; +import { ExtHostNotebookRenderers } from 'vs/workbench/api/common/extHostNotebookRenderers'; export interface IExtensionApiFactory { (extension: IExtensionDescription, registry: ExtensionDescriptionRegistry, configProvider: ExtHostConfigProvider): typeof vscode; @@ -146,6 +147,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostDocumentSaveParticipant = rpcProtocol.set(ExtHostContext.ExtHostDocumentSaveParticipant, new ExtHostDocumentSaveParticipant(extHostLogService, extHostDocuments, rpcProtocol.getProxy(MainContext.MainThreadBulkEdits))); const extHostNotebook = rpcProtocol.set(ExtHostContext.ExtHostNotebook, new ExtHostNotebookController(rpcProtocol, extHostCommands, extHostDocumentsAndEditors, extHostDocuments, extHostLogService, extensionStoragePaths)); const extHostNotebookKernels = rpcProtocol.set(ExtHostContext.ExtHostNotebookKernels, new ExtHostNotebookKernels(rpcProtocol, initData, extHostNotebook, extHostLogService)); + const extHostNotebookRenderers = rpcProtocol.set(ExtHostContext.ExtHostNotebookRenderers, new ExtHostNotebookRenderers(rpcProtocol, extHostNotebook)); const extHostEditors = rpcProtocol.set(ExtHostContext.ExtHostEditors, new ExtHostEditors(rpcProtocol, extHostDocumentsAndEditors)); const extHostTreeViews = rpcProtocol.set(ExtHostContext.ExtHostTreeViews, new ExtHostTreeViews(rpcProtocol.getProxy(MainContext.MainThreadTreeViews), extHostCommands, extHostLogService)); const extHostEditorInsets = rpcProtocol.set(ExtHostContext.ExtHostEditorInsets, new ExtHostEditorInsets(rpcProtocol.getProxy(MainContext.MainThreadEditorInsets), extHostEditors, { ...initData.environment, remote: initData.remote })); @@ -1081,6 +1083,10 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension); return extHostNotebook.createNotebookEditorDecorationType(options); }, + createRendererMessaging(rendererId) { + checkProposedApiEnabled(extension); + return extHostNotebookRenderers.createRendererMessaging(rendererId); + }, onDidChangeNotebookDocumentMetadata(listener, thisArgs?, disposables?) { checkProposedApiEnabled(extension); return extHostNotebook.onDidChangeNotebookDocumentMetadata(listener, thisArgs, disposables); diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 98368960f85..68dedb83461 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -916,6 +916,10 @@ export interface MainThreadNotebookKernelsShape extends IDisposable { $updateNotebookPriority(handle: number, uri: UriComponents, value: number | undefined): void; } +export interface MainThreadNotebookRenderersShape extends IDisposable { + $postMessage(rendererId: string, message: unknown): void; +} + export interface MainThreadUrlsShape extends IDisposable { $registerUriHandler(handle: number, extensionId: ExtensionIdentifier): Promise; $unregisterUriHandler(handle: number): Promise; @@ -1917,6 +1921,10 @@ export interface ExtHostNotebookShape extends ExtHostNotebookDocumentsAndEditors $notebookToData(handle: number, data: NotebookDataDto, token: CancellationToken): Promise; } +export interface ExtHostNotebookRenderersShape { + $postRendererMessage(editorId: string, rendererId: string, message: unknown): void; +} + export interface ExtHostNotebookDocumentsAndEditorsShape { $acceptDocumentAndEditorsDelta(delta: INotebookDocumentsAndEditorsDelta): void; } @@ -2070,6 +2078,7 @@ export const MainContext = { MainThreadNotebookDocuments: createMainId('MainThreadNotebookDocumentsShape'), MainThreadNotebookEditors: createMainId('MainThreadNotebookEditorsShape'), MainThreadNotebookKernels: createMainId('MainThreadNotebookKernels'), + MainThreadNotebookRenderers: createMainId('MainThreadNotebookRenderers'), MainThreadTheming: createMainId('MainThreadTheming'), MainThreadTunnelService: createMainId('MainThreadTunnelService'), MainThreadTimeline: createMainId('MainThreadTimeline'), @@ -2117,6 +2126,7 @@ export const ExtHostContext = { ExtHosLabelService: createMainId('ExtHostLabelService'), ExtHostNotebook: createMainId('ExtHostNotebook'), ExtHostNotebookKernels: createMainId('ExtHostNotebookKernels'), + ExtHostNotebookRenderers: createMainId('ExtHostNotebookRenderers'), ExtHostTheming: createMainId('ExtHostTheming'), ExtHostTunnelService: createMainId('ExtHostTunnelService'), ExtHostAuthentication: createMainId('ExtHostAuthentication'), diff --git a/src/vs/workbench/api/common/extHostNotebookRenderers.ts b/src/vs/workbench/api/common/extHostNotebookRenderers.ts new file mode 100644 index 00000000000..0d175f5c561 --- /dev/null +++ b/src/vs/workbench/api/common/extHostNotebookRenderers.ts @@ -0,0 +1,56 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter } from 'vs/base/common/event'; +import { ExtHostNotebookRenderersShape, IMainContext, MainContext, MainThreadNotebookRenderersShape } from 'vs/workbench/api/common/extHost.protocol'; +import { ExtHostNotebookController } from 'vs/workbench/api/common/extHostNotebook'; +import * as vscode from 'vscode'; + +export class ExtHostNotebookRenderers implements ExtHostNotebookRenderersShape { + private readonly _rendererMessageEmitters = new Map>(); + private readonly proxy: MainThreadNotebookRenderersShape; + + constructor(mainContext: IMainContext, private readonly _extHostNotebook: ExtHostNotebookController) { + this.proxy = mainContext.getProxy(MainContext.MainThreadNotebookRenderers); + } + + public $postRendererMessage(editorId: string, rendererId: string, message: unknown): void { + const editor = this._extHostNotebook.getEditorById(editorId); + if (!editor) { + return; + } + + this._rendererMessageEmitters.get(rendererId)?.fire({ document: editor.apiEditor, data: message }); + } + + public createRendererMessaging(rendererId: string): vscode.NotebookRendererMessaging { + const emitter = this.getOrCreateEmitterFor(rendererId); + const listener = emitter.event(e => messaging.messageHandler?.(e.document, e.data)); + const messaging: vscode.NotebookRendererMessaging = { + dispose: () => listener.dispose(), + postMessage: message => this.proxy.$postMessage(rendererId, message), + }; + + return messaging; + } + + private getOrCreateEmitterFor(rendererId: string) { + let emitter = this._rendererMessageEmitters.get(rendererId); + if (emitter) { + return emitter; + } + + emitter = new Emitter({ + onLastListenerRemove: () => { + emitter?.dispose(); + this._rendererMessageEmitters.delete(rendererId); + } + }); + + this._rendererMessageEmitters.set(rendererId, emitter); + + return emitter; + } +} diff --git a/src/vs/workbench/contrib/notebook/browser/diff/notebookTextDiffEditor.ts b/src/vs/workbench/contrib/notebook/browser/diff/notebookTextDiffEditor.ts index 9db388ec10e..d7b4f046c7f 100644 --- a/src/vs/workbench/contrib/notebook/browser/diff/notebookTextDiffEditor.ts +++ b/src/vs/workbench/contrib/notebook/browser/diff/notebookTextDiffEditor.ts @@ -378,7 +378,7 @@ export class NotebookTextDiffEditor extends EditorPane implements INotebookTextD this._modifiedWebview.dispose(); } - this._modifiedWebview = this.instantiationService.createInstance(BackLayerWebView, this, id, resource, this._notebookOptions.computeDiffWebviewOptions()) as BackLayerWebView; + this._modifiedWebview = this.instantiationService.createInstance(BackLayerWebView, this, id, resource, this._notebookOptions.computeDiffWebviewOptions(), undefined) as BackLayerWebView; // attach the webview container to the DOM tree first this._list.rowsContainer.insertAdjacentElement('afterbegin', this._modifiedWebview.element); await this._modifiedWebview.createWebview(); @@ -391,7 +391,7 @@ export class NotebookTextDiffEditor extends EditorPane implements INotebookTextD this._originalWebview.dispose(); } - this._originalWebview = this.instantiationService.createInstance(BackLayerWebView, this, id, resource, this._notebookOptions.computeDiffWebviewOptions()) as BackLayerWebView; + this._originalWebview = this.instantiationService.createInstance(BackLayerWebView, this, id, resource, this._notebookOptions.computeDiffWebviewOptions(), undefined) as BackLayerWebView; // attach the webview container to the DOM tree first this._list.rowsContainer.insertAdjacentElement('afterbegin', this._originalWebview.element); await this._originalWebview.createWebview(); diff --git a/src/vs/workbench/contrib/notebook/browser/extensionPoint.ts b/src/vs/workbench/contrib/notebook/browser/extensionPoint.ts index 959e70bf586..e925a6fdf98 100644 --- a/src/vs/workbench/contrib/notebook/browser/extensionPoint.ts +++ b/src/vs/workbench/contrib/notebook/browser/extensionPoint.ts @@ -30,6 +30,7 @@ namespace NotebookRendererContribution { export const entrypoint = 'entrypoint'; export const hardDependencies = 'dependencies'; export const optionalDependencies = 'optionalDependencies'; + export const requiresMessaging = 'requiresMessaging'; } export interface INotebookRendererContribution { @@ -40,6 +41,7 @@ export interface INotebookRendererContribution { readonly [NotebookRendererContribution.entrypoint]: NotebookRendererEntrypoint; readonly [NotebookRendererContribution.hardDependencies]: readonly string[]; readonly [NotebookRendererContribution.optionalDependencies]: readonly string[]; + readonly [NotebookRendererContribution.requiresMessaging]: boolean | 'optional' | undefined; } const notebookProviderContribution: IJSONSchema = { @@ -164,6 +166,20 @@ const notebookRendererContribution: IJSONSchema = { items: { type: 'string' }, markdownDescription: nls.localize('contributes.notebook.renderer.optionalDependencies', 'List of soft kernel dependencies the renderer can make use of. If any of the dependencies are present in the `NotebookKernel.preloads`, the renderer will be preferred over renderers that don\'t interact with the kernel.'), }, + [NotebookRendererContribution.requiresMessaging]: { + default: false, + enum: [ + true, + false, + 'optional' + ], + enumDescriptions: [ + nls.localize('contributes.notebook.renderer.requiresMessaging.true', 'Messaging is required. The renderer will only be used when it\'s part of an extension that can be run in an extension host.'), + nls.localize('contributes.notebook.renderer.requiresMessaging.optional', 'The renderer is better with messaging available, but it\'s not requried.'), + nls.localize('contributes.notebook.renderer.requiresMessaging.false', 'The renderer does not require messaging.'), + ], + description: nls.localize('contributes.notebook.renderer.requiresMessaging', 'Defines how and if the renderer needs to communicate with an extension host, via `createRendererMessaging`. Renderers with stronger messaging requirements may not work in all environments.'), + }, } } }; diff --git a/src/vs/workbench/contrib/notebook/browser/notebook.contribution.ts b/src/vs/workbench/contrib/notebook/browser/notebook.contribution.ts index f0051ae83d7..34d93102f1f 100644 --- a/src/vs/workbench/contrib/notebook/browser/notebook.contribution.ts +++ b/src/vs/workbench/contrib/notebook/browser/notebook.contribution.ts @@ -82,6 +82,8 @@ import 'vs/workbench/contrib/notebook/browser/view/output/transforms/richTransfo import { ILabelService } from 'vs/platform/label/common/label'; import { IWorkingCopyBackupService } from 'vs/workbench/services/workingCopy/common/workingCopyBackup'; import { IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService'; +import { NotebookRendererMessagingService } from 'vs/workbench/contrib/notebook/browser/notebookRendererMessagingServiceImpl'; +import { INotebookRendererMessagingService } from 'vs/workbench/contrib/notebook/common/notebookRendererMessagingService'; /*--------------------------------------------------------------------------------------------- */ @@ -544,6 +546,7 @@ registerSingleton(INotebookEditorModelResolverService, NotebookModelResolverServ registerSingleton(INotebookCellStatusBarService, NotebookCellStatusBarService, true); registerSingleton(INotebookEditorService, NotebookEditorWidgetService, true); registerSingleton(INotebookKernelService, NotebookKernelService, true); +registerSingleton(INotebookRendererMessagingService, NotebookRendererMessagingService, true); const configurationRegistry = Registry.as(Extensions.Configuration); configurationRegistry.registerConfiguration({ diff --git a/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts b/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts index 077b33d94c3..a4c92f90a75 100644 --- a/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts +++ b/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts @@ -42,7 +42,7 @@ import { IEditorMemento } from 'vs/workbench/common/editor'; import { Memento, MementoObject } from 'vs/workbench/common/memento'; import { PANEL_BORDER } from 'vs/workbench/common/theme'; import { debugIconStartForeground } from 'vs/workbench/contrib/debug/browser/debugColors'; -import { CellEditState, CellFocusMode, IActiveNotebookEditor, ICellOutputViewModel, ICellViewModel, ICommonCellInfo, IDisplayOutputLayoutUpdateRequest, IFocusNotebookCellOptions, IGenericCellViewModel, IInsetRenderOutput, INotebookCellList, INotebookCellOutputLayoutInfo, INotebookDeltaDecoration, INotebookEditor, INotebookEditorContribution, INotebookEditorContributionDescription, INotebookEditorCreationOptions, INotebookEditorMouseEvent, NotebookEditorOptions, NotebookLayoutInfo, NOTEBOOK_EDITOR_EDITABLE, NOTEBOOK_EDITOR_FOCUSED, NOTEBOOK_EDITOR_ID, NOTEBOOK_OUTPUT_FOCUSED } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; +import { CellEditState, CellFocusMode, IActiveNotebookEditor, ICellOutputViewModel, ICellViewModel, ICommonCellInfo, IDisplayOutputLayoutUpdateRequest, IFocusNotebookCellOptions, IGenericCellViewModel, IInsetRenderOutput, INotebookCellList, INotebookCellOutputLayoutInfo, INotebookDeltaDecoration, INotebookEditor, INotebookEditorContribution, INotebookEditorContributionDescription, INotebookEditorCreationOptions, INotebookEditorMouseEvent, NotebookEditorOptions, NotebookLayoutInfo, NOTEBOOK_EDITOR_EDITABLE, NOTEBOOK_EDITOR_FOCUSED, NOTEBOOK_EDITOR_ID, NOTEBOOK_OUTPUT_FOCUSED, RenderOutputType } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; import { NotebookDecorationCSSRules, NotebookRefCountedStyleSheet } from 'vs/workbench/contrib/notebook/browser/notebookEditorDecorations'; import { NotebookEditorExtensionsRegistry } from 'vs/workbench/contrib/notebook/browser/notebookEditorExtensions'; import { NotebookEditorKernelManager } from 'vs/workbench/contrib/notebook/browser/notebookEditorKernelManager'; @@ -72,6 +72,7 @@ import { NotebookEditorContextKeys } from 'vs/workbench/contrib/notebook/browser import { NotebookOptions } from 'vs/workbench/contrib/notebook/common/notebookOptions'; import { ViewContext } from 'vs/workbench/contrib/notebook/browser/viewModel/viewContext'; import { NotebookEditorToolbar } from 'vs/workbench/contrib/notebook/browser/notebookEditorToolbar'; +import { INotebookRendererMessagingService } from 'vs/workbench/contrib/notebook/common/notebookRendererMessagingService'; const $ = DOM.$; @@ -316,6 +317,7 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditor @IInstantiationService instantiationService: IInstantiationService, @IStorageService storageService: IStorageService, @IAccessibilityService accessibilityService: IAccessibilityService, + @INotebookRendererMessagingService private readonly notebookRendererMessaging: INotebookRendererMessagingService, @INotebookEditorService private readonly notebookEditorService: INotebookEditorService, @INotebookKernelService private readonly notebookKernelService: INotebookKernelService, @IConfigurationService private readonly configurationService: IConfigurationService, @@ -1108,7 +1110,7 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditor } private async _createWebview(id: string, resource: URI): Promise { - this._webview = this.instantiationService.createInstance(BackLayerWebView, this, id, resource, this._notebookOptions.computeWebviewOptions()); + this._webview = this.instantiationService.createInstance(BackLayerWebView, this, id, resource, this._notebookOptions.computeWebviewOptions(), this.notebookRendererMessaging.getScoped(this._uuid)); this._webview.element.style.width = '100%'; // attach the webview container to the DOM tree first @@ -2259,6 +2261,10 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditor return; } + if (output.type === RenderOutputType.Extension) { + this.notebookRendererMessaging.prepare(output.renderer.id); + } + const cellTop = this._list.getAbsoluteTopOfElement(cell); if (!this._webview.insetMapping.has(output.source)) { await this._webview.createOutput({ cellId: cell.id, cellHandle: cell.handle, cellUri: cell.uri }, output, cellTop, offset); diff --git a/src/vs/workbench/contrib/notebook/browser/notebookRendererMessagingServiceImpl.ts b/src/vs/workbench/contrib/notebook/browser/notebookRendererMessagingServiceImpl.ts new file mode 100644 index 00000000000..6524ebb4224 --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/notebookRendererMessagingServiceImpl.ts @@ -0,0 +1,70 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter } from 'vs/base/common/event'; +import { INotebookRendererMessagingService, IScopedRendererMessaging } from 'vs/workbench/contrib/notebook/common/notebookRendererMessagingService'; +import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; + +type MessageToSend = { editorId: string; rendererId: string; message: unknown }; + +export class NotebookRendererMessagingService implements INotebookRendererMessagingService { + declare _serviceBrand: undefined; + /** + * Activation promises. Maps renderer IDs to a queue of messages that should + * be sent once activation finishes, or undefined if activation is complete. + */ + private readonly activations = new Map(); + private readonly receiveMessageEmitter = new Emitter<{ rendererId: string, message: unknown }>(); + public readonly onDidReceiveMessage = this.receiveMessageEmitter.event; + private readonly postMessageEmitter = new Emitter(); + public readonly onShouldPostMessage = this.postMessageEmitter.event; + + constructor(@IExtensionService private readonly extensionService: IExtensionService) { } + + /** @inheritdoc */ + public fireDidReceiveMessage(rendererId: string, message: unknown): void { + this.receiveMessageEmitter.fire({ rendererId, message }); + } + + /** @inheritdoc */ + public prepare(rendererId: string) { + if (this.activations.has(rendererId)) { + return; + } + + const queue: MessageToSend[] = []; + this.activations.set(rendererId, queue); + + this.extensionService.activateByEvent(`onRenderer:${rendererId}`).then(() => { + for (const message of queue) { + this.postMessageEmitter.fire(message); + } + + this.activations.set(rendererId, undefined); + }); + } + + /** @inheritdoc */ + public getScoped(editorId: string): IScopedRendererMessaging { + return { + onDidReceiveMessage: this.onDidReceiveMessage, + postMessage: (rendererId, message) => this.postMessage(editorId, rendererId, message), + }; + } + + private postMessage(editorId: string, rendererId: string, message: unknown): void { + if (!this.activations.has(rendererId)) { + this.prepare(rendererId); + } + + const activation = this.activations.get(rendererId); + const toSend = { rendererId, editorId, message }; + if (activation === undefined) { + this.postMessageEmitter.fire(toSend); + } else { + activation.push(toSend); + } + } +} diff --git a/src/vs/workbench/contrib/notebook/browser/notebookServiceImpl.ts b/src/vs/workbench/contrib/notebook/browser/notebookServiceImpl.ts index 05db784c920..90c4f8bc43e 100644 --- a/src/vs/workbench/contrib/notebook/browser/notebookServiceImpl.ts +++ b/src/vs/workbench/contrib/notebook/browser/notebookServiceImpl.ts @@ -359,7 +359,7 @@ export class NotebookService extends Disposable implements INotebookService { continue; } - this._notebookRenderersInfoStore.add(new NotebookOutputRendererInfo({ + this._notebookRenderersInfoStore.add(this._instantiationService.createInstance(NotebookOutputRendererInfo, { id, extension: extension.description, entrypoint: notebookContribution.entrypoint, @@ -367,6 +367,7 @@ export class NotebookService extends Disposable implements INotebookService { mimeTypes: notebookContribution.mimeTypes || [], dependencies: notebookContribution.dependencies, optionalDependencies: notebookContribution.optionalDependencies, + requiresMessaging: notebookContribution.requiresMessaging, })); } } diff --git a/src/vs/workbench/contrib/notebook/browser/view/renderers/backLayerWebView.ts b/src/vs/workbench/contrib/notebook/browser/view/renderers/backLayerWebView.ts index a48496cf9e4..b8ff078934f 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/renderers/backLayerWebView.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/renderers/backLayerWebView.ts @@ -30,6 +30,7 @@ import { preloadsScriptStr, RendererMetadata } from 'vs/workbench/contrib/notebo import { transformWebviewThemeVars } from 'vs/workbench/contrib/notebook/browser/view/renderers/webviewThemeMapping'; import { MarkdownCellViewModel } from 'vs/workbench/contrib/notebook/browser/viewModel/markdownCellViewModel'; import { INotebookKernel, INotebookRendererInfo } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { IScopedRendererMessaging } from 'vs/workbench/contrib/notebook/common/notebookRendererMessagingService'; import { INotebookService } from 'vs/workbench/contrib/notebook/common/notebookService'; import { IWebviewService, WebviewContentPurpose, WebviewElement } from 'vs/workbench/contrib/webview/browser/webview'; import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService'; @@ -286,6 +287,12 @@ export interface ICustomKernelMessage extends BaseToWebviewMessage { message: unknown; } +export interface ICustomRendererMessage extends BaseToWebviewMessage { + type: 'customRendererMessage'; + rendererId: string; + message: unknown; +} + export interface ICreateMarkdownMessage { type: 'createMarkdownPreview', cell: IMarkdownCellInitialization; @@ -349,6 +356,7 @@ export type FromWebviewMessage = | IScrollAckMessage | IBlurOutputMessage | ICustomKernelMessage + | ICustomRendererMessage | IClickedDataUrlMessage | IClickMarkdownPreviewMessage | IContextMenuMarkdownPreviewMessage @@ -377,6 +385,7 @@ export type ToWebviewMessage = | IUpdateControllerPreloadsMessage | IUpdateDecorationsMessage | ICustomKernelMessage + | ICustomRendererMessage | ICreateMarkdownMessage | IDeleteMarkdownMessage | IShowMarkdownMessage @@ -440,6 +449,7 @@ export class BackLayerWebView extends Disposable { rightMargin: number, runGutter: number, }, + private readonly rendererMessaging: IScopedRendererMessaging | undefined, @IWebviewService readonly webviewService: IWebviewService, @IOpenerService readonly openerService: IOpenerService, @INotebookService private readonly notebookService: INotebookService, @@ -458,6 +468,17 @@ export class BackLayerWebView extends Disposable { this.element.style.height = '1400px'; this.element.style.position = 'absolute'; + + if (rendererMessaging) { + this._register(rendererMessaging.onDidReceiveMessage(evt => { + this._sendMessageToWebview({ + __vscode_notebook_message: true, + type: 'customRendererMessage', + rendererId: evt.rendererId, + message: evt.message + }); + })); + } } updateOptions(options: { @@ -766,6 +787,7 @@ export class BackLayerWebView extends Disposable { entrypoint, mimeTypes: renderer.mimeTypes, extends: renderer.extends, + messaging: !!renderer.messaging, }; }); } @@ -877,6 +899,10 @@ var requirejs = (function() { return; } + if (matchesScheme(link, Schemas.command)) { + console.warn('Command links are deprecated and will be removed, use messag passing instead: https://github.com/microsoft/vscode/issues/123601'); + } + if (matchesScheme(link, Schemas.http) || matchesScheme(link, Schemas.https) || matchesScheme(link, Schemas.mailto) || matchesScheme(link, Schemas.command)) { this.openerService.open(link, { fromUserGesture: true, allowContributedOpeners: true, allowCommands: true }); @@ -1001,6 +1027,11 @@ var requirejs = (function() { this._onMessage.fire({ message: data.message }); break; } + case 'customRendererMessage': + { + this.rendererMessaging?.postMessage(data.rendererId, data.message); + break; + } case 'clickMarkdownPreview': { const cell = this.notebookEditor.getCellById(data.cellId); diff --git a/src/vs/workbench/contrib/notebook/browser/view/renderers/webviewPreloads.ts b/src/vs/workbench/contrib/notebook/browser/view/renderers/webviewPreloads.ts index f70fb7c26c7..c05e38d2bc4 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/renderers/webviewPreloads.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/renderers/webviewPreloads.ts @@ -124,19 +124,9 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Rend interface RendererContext { getState(): T | undefined; setState(newState: T): void; - getRenderer(id: string): any | undefined; - } - - function createRendererContext(rendererId: string): RendererContext { - return { - setState: newState => vscode.setState({ ...vscode.getState(), [rendererId]: newState }), - getState: () => { - const state = vscode.getState(); - return typeof state === 'object' && state ? state[rendererId] as T : undefined; - }, - getRenderer: (id: string) => renderers.getRenderer(id), - }; + postMessage?(message: unknown): void; + onDidReceiveMessage?: Event; } interface ScriptModule { @@ -773,6 +763,9 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Rend case 'customKernelMessage': onDidReceiveKernelMessage.fire(event.data.message); break; + case 'customRendererMessage': + renderers.getRenderer(event.data.rendererId)?.receiveMessage(event.data.message); + break; case 'notebookStyles': const documentStyle = document.documentElement.style; @@ -804,6 +797,7 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Rend private readonly loadExtension: (id: string) => Promise, ) { } + private _onMessageEvent = createEmitter(); private _loadPromise: Promise | undefined; private _api: RendererApi | undefined; @@ -817,6 +811,29 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Rend return this._loadPromise; } + public receiveMessage(message: unknown) { + this._onMessageEvent.fire(message); + } + + private createRendererContext(): RendererContext { + const { id, messaging } = this.data; + const context: RendererContext = { + setState: newState => vscode.setState({ ...vscode.getState(), [id]: newState }), + getState: () => { + const state = vscode.getState(); + return typeof state === 'object' && state ? state[id] as T : undefined; + }, + getRenderer: (id: string) => renderers.getRenderer(id)?.api, + }; + + if (messaging) { + context.onDidReceiveMessage = this._onMessageEvent.event; + context.postMessage = message => postNotebookMessage('customRendererMessage', { rendererId: id, message }); + } + + return context; + } + /** Inner function cached in the _loadPromise(). */ private async _load() { const module = await runRenderScript(this.data.entrypoint, this.data.id); @@ -824,7 +841,7 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Rend return; } - const api = module.activate(createRendererContext(this.data.id)); + const api = module.activate(this.createRendererContext()); this._api = api; // Squash any errors extends errors. They won't prevent the renderer @@ -925,8 +942,8 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Rend } } - public getRenderer(id: string): RendererApi | undefined { - return this._renderers.get(id)?.api; + public getRenderer(id: string) { + return this._renderers.get(id); } public async load(id: string) { @@ -1211,6 +1228,7 @@ export interface RendererMetadata { readonly entrypoint: string; readonly mimeTypes: readonly string[]; readonly extends: string | undefined; + readonly messaging: boolean; } export function preloadsScriptStr(styleValues: PreloadStyles, renderers: readonly RendererMetadata[]) { diff --git a/src/vs/workbench/contrib/notebook/common/notebookCommon.ts b/src/vs/workbench/contrib/notebook/common/notebookCommon.ts index d8fcf0068cb..8eda48a57b3 100644 --- a/src/vs/workbench/contrib/notebook/common/notebookCommon.ts +++ b/src/vs/workbench/contrib/notebook/common/notebookCommon.ts @@ -131,6 +131,8 @@ export const enum NotebookRendererMatch { Never = 3, } +export type RendererMessagingSpec = true | false | 'optional'; + export interface INotebookRendererInfo { id: string; displayName: string; @@ -139,6 +141,7 @@ export interface INotebookRendererInfo { preloads: ReadonlyArray; extensionLocation: URI; extensionId: ExtensionIdentifier; + messaging: RendererMessagingSpec; readonly mimeTypes: readonly string[]; diff --git a/src/vs/workbench/contrib/notebook/common/notebookOutputRenderer.ts b/src/vs/workbench/contrib/notebook/common/notebookOutputRenderer.ts index 5025db38aeb..3ce2880b1d6 100644 --- a/src/vs/workbench/contrib/notebook/common/notebookOutputRenderer.ts +++ b/src/vs/workbench/contrib/notebook/common/notebookOutputRenderer.ts @@ -8,7 +8,8 @@ import { Iterable } from 'vs/base/common/iterator'; import { joinPath } from 'vs/base/common/resources'; import { URI } from 'vs/base/common/uri'; import { ExtensionIdentifier, IExtensionDescription } from 'vs/platform/extensions/common/extensions'; -import { INotebookRendererInfo, NotebookRendererEntrypoint, NotebookRendererMatch } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { INotebookRendererInfo, NotebookRendererEntrypoint, NotebookRendererMatch, RendererMessagingSpec } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; class DependencyList { private readonly value: ReadonlySet; @@ -41,6 +42,7 @@ export class NotebookOutputRendererInfo implements INotebookRendererInfo { readonly extensionId: ExtensionIdentifier; readonly hardDependencies: DependencyList; readonly optionalDependencies: DependencyList; + readonly messaging: RendererMessagingSpec; // todo: re-add preloads in pure renderer API readonly preloads: ReadonlyArray = []; @@ -55,7 +57,8 @@ export class NotebookOutputRendererInfo implements INotebookRendererInfo { readonly extension: IExtensionDescription; readonly dependencies: readonly string[] | undefined; readonly optionalDependencies: readonly string[] | undefined; - }) { + readonly requiresMessaging: RendererMessagingSpec | undefined; + }, @IExtensionService public readonly extensions: IExtensionService) { this.id = descriptor.id; this.extensionId = descriptor.extension.identifier; this.extensionLocation = descriptor.extension.extensionLocation; @@ -72,6 +75,7 @@ export class NotebookOutputRendererInfo implements INotebookRendererInfo { this.mimeTypeGlobs = this.mimeTypes.map(pattern => glob.parse(pattern)); this.hardDependencies = new DependencyList(descriptor.dependencies ?? Iterable.empty()); this.optionalDependencies = new DependencyList(descriptor.optionalDependencies ?? Iterable.empty()); + this.messaging = descriptor.requiresMessaging ?? false; } get dependencies(): string[] { @@ -83,6 +87,12 @@ export class NotebookOutputRendererInfo implements INotebookRendererInfo { return NotebookRendererMatch.Never; } + // todo@connor4312 this a no-op since extensions that can't run are never + // shared as a contribution + if (this.messaging === true && !this.extensions.getExtensionsStatus()[this.extensionId.value]) { + return NotebookRendererMatch.Never; + } + if (this.hardDependencies.defined) { return NotebookRendererMatch.WithHardKernelDependency; } diff --git a/src/vs/workbench/contrib/notebook/common/notebookRendererMessagingService.ts b/src/vs/workbench/contrib/notebook/common/notebookRendererMessagingService.ts new file mode 100644 index 00000000000..0f00b8f6af3 --- /dev/null +++ b/src/vs/workbench/contrib/notebook/common/notebookRendererMessagingService.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 { Event } from 'vs/base/common/event'; +import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; + +export const INotebookRendererMessagingService = createDecorator('INotebookRendererMessagingService'); + +export interface INotebookRendererMessagingService { + readonly _serviceBrand: undefined; + + /** + * Event that fires when a message should be posted to extension hosts. + */ + onShouldPostMessage: Event<{ editorId: string; rendererId: string; message: unknown }>; + + /** + * Prepares messaging for the given renderer ID. + */ + prepare(rendererId: string): void; + /** + * Gets messaging scoped for a specific editor. + */ + getScoped(editorId: string): IScopedRendererMessaging; + + /** + * Called when the main thread gets a message for a renderer. + */ + fireDidReceiveMessage(rendererId: string, message: unknown): void; +} + +export interface IScopedRendererMessaging { + /** + * Event that fires when a message is received. + */ + onDidReceiveMessage: Event<{ rendererId: string; message: unknown }>; + + /** + * Sends a message to an extension from a renderer. + */ + postMessage(rendererId: string, message: unknown): void; +} diff --git a/src/vs/workbench/contrib/notebook/test/notebookRendererMessagingService.test.ts b/src/vs/workbench/contrib/notebook/test/notebookRendererMessagingService.test.ts new file mode 100644 index 00000000000..b5c2774c766 --- /dev/null +++ b/src/vs/workbench/contrib/notebook/test/notebookRendererMessagingService.test.ts @@ -0,0 +1,59 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { NullExtensionService } from 'vs/workbench/services/extensions/common/extensions'; +import { stub } from 'sinon'; +import { NotebookRendererMessagingService } from 'vs/workbench/contrib/notebook/browser/notebookRendererMessagingServiceImpl'; +import * as assert from 'assert'; +import { timeout } from 'vs/base/common/async'; + +suite('NotebookRendererMessaging', () => { + let extService: NullExtensionService; + let m: NotebookRendererMessagingService; + let sent: unknown[] = []; + let received: unknown[] = []; + + setup(() => { + sent = []; + extService = new NullExtensionService(); + m = new NotebookRendererMessagingService(extService); + m.onShouldPostMessage(e => sent.push(e)); + m.onDidReceiveMessage(e => received.push(e)); + }); + + test('activates on prepare', () => { + const activate = stub(extService, 'activateByEvent').returns(Promise.resolve()); + m.prepare('foo'); + m.prepare('foo'); + m.prepare('foo'); + + assert.deepStrictEqual(activate.args, [['onRenderer:foo']]); + }); + + test('buffers and then plays events', async () => { + stub(extService, 'activateByEvent').returns(Promise.resolve()); + + const scoped = m.getScoped('some-editor'); + scoped.postMessage('foo', 1); + scoped.postMessage('foo', 2); + assert.deepStrictEqual(sent, []); + + await timeout(0); + + const expected = [ + { editorId: 'some-editor', rendererId: 'foo', message: 1 }, + { editorId: 'some-editor', rendererId: 'foo', message: 2 } + ]; + + assert.deepStrictEqual(sent, expected); + + scoped.postMessage('foo', 3); + + assert.deepStrictEqual(sent, [ + ...expected, + { editorId: 'some-editor', rendererId: 'foo', message: 3 } + ]); + }); +}); diff --git a/src/vs/workbench/services/extensions/common/extensionsRegistry.ts b/src/vs/workbench/services/extensions/common/extensionsRegistry.ts index dca05b7ca6d..91eac5e38c3 100644 --- a/src/vs/workbench/services/extensions/common/extensionsRegistry.ts +++ b/src/vs/workbench/services/extensions/common/extensionsRegistry.ts @@ -320,6 +320,11 @@ export const schema: IJSONSchema = { body: 'onAuthenticationRequest:${11:authenticationProviderId}', description: nls.localize('vscode.extension.activationEvents.onAuthenticationRequest', 'An activation event emitted whenever sessions are requested from the specified authentication provider.') }, + { + label: 'onRenderer', + description: nls.localize('vscode.extension.activationEvents.onRenderer', 'An activation event emitted whenever a notebook output renderer is used.'), + body: 'onRenderer:${11:rendererId}' + }, { label: '*', description: nls.localize('vscode.extension.activationEvents.star', 'An activation event emitted on VS Code startup. To ensure a great end user experience, please use this activation event in your extension only when no other activation events combination works in your use-case.'),