From 6db81f6ab28257ba768ec9e0cb7f9e962418893a Mon Sep 17 00:00:00 2001 From: Matt Bierner Date: Thu, 20 Aug 2020 15:48:14 -0700 Subject: [PATCH] Move custom editors into own ext host services --- .../api/browser/mainThreadWebview.ts | 23 +- .../workbench/api/common/extHost.api.impl.ts | 6 +- .../workbench/api/common/extHost.protocol.ts | 3 + .../api/common/extHostCustomEditors.ts | 386 ++++++++++++++++++ src/vs/workbench/api/common/extHostWebview.ts | 382 +---------------- .../api/common/extHostWebviewView.ts | 2 +- .../test/browser/api/extHostWebview.test.ts | 24 +- 7 files changed, 432 insertions(+), 394 deletions(-) create mode 100644 src/vs/workbench/api/common/extHostCustomEditors.ts diff --git a/src/vs/workbench/api/browser/mainThreadWebview.ts b/src/vs/workbench/api/browser/mainThreadWebview.ts index e1a231fc36f..8342aa54aa2 100644 --- a/src/vs/workbench/api/browser/mainThreadWebview.ts +++ b/src/vs/workbench/api/browser/mainThreadWebview.ts @@ -118,7 +118,9 @@ export class MainThreadWebviews extends Disposable implements extHostProtocol.Ma ]); private readonly _proxy: extHostProtocol.ExtHostWebviewsShape; - private readonly _viewsProxy: extHostProtocol.ExtHostWebviewViewsShape; + private readonly _proxyViews: extHostProtocol.ExtHostWebviewViewsShape; + private readonly _proxyCustomEditors: extHostProtocol.ExtHostCustomEditorsShape; + private readonly _webviewInputs = new WebviewInputStore(); private readonly _revivers = new Map(); @@ -147,7 +149,8 @@ export class MainThreadWebviews extends Disposable implements extHostProtocol.Ma super(); this._proxy = context.getProxy(extHostProtocol.ExtHostContext.ExtHostWebviews); - this._viewsProxy = context.getProxy(extHostProtocol.ExtHostContext.ExtHostWebviewViews); + this._proxyViews = context.getProxy(extHostProtocol.ExtHostContext.ExtHostWebviewViews); + this._proxyCustomEditors = context.getProxy(extHostProtocol.ExtHostContext.ExtHostCustomEditors); this._register(_editorService.onDidActiveEditorChange(() => { const activeInput = this._editorService.activeEditor; @@ -358,15 +361,15 @@ export class MainThreadWebviews extends Disposable implements extHostProtocol.Ma } webviewView.onDidChangeVisibility(visible => { - this._viewsProxy.$onDidChangeWebviewViewVisibility(handle, visible); + this._proxyViews.$onDidChangeWebviewViewVisibility(handle, visible); }); webviewView.onDispose(() => { - this._viewsProxy.$disposeWebviewView(handle); + this._proxyViews.$disposeWebviewView(handle); }); try { - await this._viewsProxy.$resolveWebviewView(handle, viewType, state, cancellation); + await this._proxyViews.$resolveWebviewView(handle, viewType, state, cancellation); } catch (error) { onUnexpectedError(error); webviewView.webview.html = MainThreadWebviews.getWebviewResolvedFailedContent(viewType); @@ -459,13 +462,13 @@ export class MainThreadWebviews extends Disposable implements extHostProtocol.Ma webviewInput.onMove(async (newResource: URI) => { const oldModel = modelRef; modelRef = await this.getOrCreateCustomEditorModel(modelType, newResource, viewType, {}, CancellationToken.None); - this._proxy.$onMoveCustomEditor(handle, newResource, viewType); + this._proxyCustomEditors.$onMoveCustomEditor(handle, newResource, viewType); oldModel.dispose(); }); } try { - await this._proxy.$resolveWebviewEditor(resource, handle, viewType, webviewInput.getTitle(), editorGroupToViewColumn(this._editorGroupService, webviewInput.group || 0), webviewInput.webview.options, cancellation); + await this._proxyCustomEditors.$resolveWebviewEditor(resource, handle, viewType, webviewInput.getTitle(), editorGroupToViewColumn(this._editorGroupService, webviewInput.group || 0), webviewInput.webview.options, cancellation); } catch (error) { onUnexpectedError(error); webviewInput.webview.html = MainThreadWebviews.getWebviewResolvedFailedContent(viewType); @@ -510,7 +513,7 @@ export class MainThreadWebviews extends Disposable implements extHostProtocol.Ma } case ModelType.Custom: { - const model = MainThreadCustomEditorModel.create(this._instantiationService, this._proxy, viewType, resource, options, () => { + const model = MainThreadCustomEditorModel.create(this._instantiationService, this._proxyCustomEditors, viewType, resource, options, () => { return Array.from(this._webviewInputs) .filter(editor => editor instanceof CustomEditorInput && isEqual(editor.resource, resource)) as CustomEditorInput[]; }, cancellation, this._backupService); @@ -721,7 +724,7 @@ class MainThreadCustomEditorModel extends Disposable implements ICustomEditorMod public static async create( instantiationService: IInstantiationService, - proxy: extHostProtocol.ExtHostWebviewsShape, + proxy: extHostProtocol.ExtHostCustomEditorsShape, viewType: string, resource: URI, options: { backupId?: string }, @@ -734,7 +737,7 @@ class MainThreadCustomEditorModel extends Disposable implements ICustomEditorMod } constructor( - private readonly _proxy: extHostProtocol.ExtHostWebviewsShape, + private readonly _proxy: extHostProtocol.ExtHostCustomEditorsShape, private readonly _viewType: string, private readonly _editorResource: URI, fromBackup: boolean, diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index d36555cf30e..3778f388571 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -77,6 +77,7 @@ import { ExtHostNotebookConcatDocument } from 'vs/workbench/api/common/extHostNo import { IExtensionStoragePaths } from 'vs/workbench/api/common/extHostStoragePaths'; import { IExtHostConsumerFileSystem } from 'vs/workbench/api/common/extHostFileSystemConsumer'; import { ExtHostWebviewViews } from 'vs/workbench/api/common/extHostWebviewView'; +import { ExtHostCustomEditors } from 'vs/workbench/api/common/extHostCustomEditors'; export interface IExtensionApiFactory { (extension: IExtensionDescription, registry: ExtensionDescriptionRegistry, configProvider: ExtHostConfigProvider): typeof vscode; @@ -142,7 +143,8 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostTheming = rpcProtocol.set(ExtHostContext.ExtHostTheming, new ExtHostTheming(rpcProtocol)); const extHostAuthentication = rpcProtocol.set(ExtHostContext.ExtHostAuthentication, new ExtHostAuthentication(rpcProtocol)); const extHostTimeline = rpcProtocol.set(ExtHostContext.ExtHostTimeline, new ExtHostTimeline(rpcProtocol, extHostCommands)); - const extHostWebviews = rpcProtocol.set(ExtHostContext.ExtHostWebviews, new ExtHostWebviews(rpcProtocol, initData.environment, extHostWorkspace, extHostLogService, extHostApiDeprecation, extHostDocuments, extensionStoragePaths)); + const extHostWebviews = rpcProtocol.set(ExtHostContext.ExtHostWebviews, new ExtHostWebviews(rpcProtocol, initData.environment, extHostWorkspace, extHostLogService, extHostApiDeprecation)); + const extHostCustomEditors = rpcProtocol.set(ExtHostContext.ExtHostCustomEditors, new ExtHostCustomEditors(rpcProtocol, extHostDocuments, extensionStoragePaths, extHostWebviews)); const extHostWebviewViews = rpcProtocol.set(ExtHostContext.ExtHostWebviewViews, new ExtHostWebviewViews(rpcProtocol, extHostWebviews)); // Check that no named customers are missing @@ -594,7 +596,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I return extHostWebviews.registerWebviewPanelSerializer(extension, viewType, serializer); }, registerCustomEditorProvider: (viewType: string, provider: vscode.CustomTextEditorProvider | vscode.CustomReadonlyEditorProvider, options: { webviewOptions?: vscode.WebviewPanelOptions, supportsMultipleEditorsPerDocument?: boolean } = {}) => { - return extHostWebviews.registerCustomEditorProvider(extension, viewType, provider, options); + return extHostCustomEditors.registerCustomEditorProvider(extension, viewType, provider, options); }, registerDecorationProvider(provider: vscode.DecorationProvider) { checkProposedApiEnabled(extension); diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 2546199fcc3..acbd96b304a 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -651,7 +651,9 @@ export interface ExtHostWebviewsShape { $onDidDisposeWebviewPanel(handle: WebviewPanelHandle): Promise; $deserializeWebviewPanel(newWebviewHandle: WebviewPanelHandle, viewType: string, title: string, state: any, position: EditorViewColumn, options: modes.IWebviewOptions & modes.IWebviewPanelOptions): Promise; +} +export interface ExtHostCustomEditorsShape { $resolveWebviewEditor(resource: UriComponents, newWebviewHandle: WebviewPanelHandle, viewType: string, title: string, position: EditorViewColumn, options: modes.IWebviewOptions & modes.IWebviewPanelOptions, cancellation: CancellationToken): Promise; $createCustomDocument(resource: UriComponents, viewType: string, backupId: string | undefined, cancellation: CancellationToken): Promise<{ editable: boolean }>; $disposeCustomDocument(resource: UriComponents, viewType: string): Promise; @@ -1749,6 +1751,7 @@ export const ExtHostContext = { ExtHostWorkspace: createExtId('ExtHostWorkspace'), ExtHostWindow: createExtId('ExtHostWindow'), ExtHostWebviews: createExtId('ExtHostWebviews'), + ExtHostCustomEditors: createExtId('ExtHostCustomEditors'), ExtHostWebviewViews: createExtId('ExtHostWebviewViews'), ExtHostEditorInsets: createExtId('ExtHostEditorInsets'), ExtHostProgress: createMainId('ExtHostProgress'), diff --git a/src/vs/workbench/api/common/extHostCustomEditors.ts b/src/vs/workbench/api/common/extHostCustomEditors.ts new file mode 100644 index 00000000000..f8275fab414 --- /dev/null +++ b/src/vs/workbench/api/common/extHostCustomEditors.ts @@ -0,0 +1,386 @@ +/*--------------------------------------------------------------------------------------------- + * 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 'vs/base/common/cancellation'; +import { hash } from 'vs/base/common/hash'; +import { DisposableStore } from 'vs/base/common/lifecycle'; +import { Schemas } from 'vs/base/common/network'; +import { joinPath } from 'vs/base/common/resources'; +import { URI, UriComponents } from 'vs/base/common/uri'; +import * as modes from 'vs/editor/common/modes'; +import { IExtensionDescription } from 'vs/platform/extensions/common/extensions'; +import { ExtHostDocuments } from 'vs/workbench/api/common/extHostDocuments'; +import { IExtensionStoragePaths } from 'vs/workbench/api/common/extHostStoragePaths'; +import { ExtHostWebviews, toExtensionData } from 'vs/workbench/api/common/extHostWebview'; +import { EditorViewColumn } from 'vs/workbench/api/common/shared/editor'; +import type * as vscode from 'vscode'; +import { Cache } from './cache'; +import * as extHostProtocol from './extHost.protocol'; +import * as extHostTypes from './extHostTypes'; + + +class CustomDocumentStoreEntry { + + private _backupCounter = 1; + + constructor( + public readonly document: vscode.CustomDocument, + private readonly _storagePath: URI | undefined, + ) { } + + private readonly _edits = new Cache('custom documents'); + + private _backup?: vscode.CustomDocumentBackup; + + addEdit(item: vscode.CustomDocumentEditEvent): number { + return this._edits.add([item]); + } + + async undo(editId: number, isDirty: boolean): Promise { + await this.getEdit(editId).undo(); + if (!isDirty) { + this.disposeBackup(); + } + } + + async redo(editId: number, isDirty: boolean): Promise { + await this.getEdit(editId).redo(); + if (!isDirty) { + this.disposeBackup(); + } + } + + disposeEdits(editIds: number[]): void { + for (const id of editIds) { + this._edits.delete(id); + } + } + + getNewBackupUri(): URI { + if (!this._storagePath) { + throw new Error('Backup requires a valid storage path'); + } + const fileName = hashPath(this.document.uri) + (this._backupCounter++); + return joinPath(this._storagePath, fileName); + } + + updateBackup(backup: vscode.CustomDocumentBackup): void { + this._backup?.delete(); + this._backup = backup; + } + + disposeBackup(): void { + this._backup?.delete(); + this._backup = undefined; + } + + private getEdit(editId: number): vscode.CustomDocumentEditEvent { + const edit = this._edits.get(editId, 0); + if (!edit) { + throw new Error('No edit found'); + } + return edit; + } +} + +class CustomDocumentStore { + private readonly _documents = new Map(); + + public get(viewType: string, resource: vscode.Uri): CustomDocumentStoreEntry | undefined { + return this._documents.get(this.key(viewType, resource)); + } + + public add(viewType: string, document: vscode.CustomDocument, storagePath: URI | undefined): CustomDocumentStoreEntry { + const key = this.key(viewType, document.uri); + if (this._documents.has(key)) { + throw new Error(`Document already exists for viewType:${viewType} resource:${document.uri}`); + } + const entry = new CustomDocumentStoreEntry(document, storagePath); + this._documents.set(key, entry); + return entry; + } + + public delete(viewType: string, document: vscode.CustomDocument) { + const key = this.key(viewType, document.uri); + this._documents.delete(key); + } + + private key(viewType: string, resource: vscode.Uri): string { + return `${viewType}@@@${resource}`; + } + +} + +const enum WebviewEditorType { + Text, + Custom +} + +type ProviderEntry = { + readonly extension: IExtensionDescription; + readonly type: WebviewEditorType.Text; + readonly provider: vscode.CustomTextEditorProvider; +} | { + readonly extension: IExtensionDescription; + readonly type: WebviewEditorType.Custom; + readonly provider: vscode.CustomReadonlyEditorProvider; +}; + +class EditorProviderStore { + private readonly _providers = new Map(); + + public addTextProvider(viewType: string, extension: IExtensionDescription, provider: vscode.CustomTextEditorProvider): vscode.Disposable { + return this.add(WebviewEditorType.Text, viewType, extension, provider); + } + + public addCustomProvider(viewType: string, extension: IExtensionDescription, provider: vscode.CustomReadonlyEditorProvider): vscode.Disposable { + return this.add(WebviewEditorType.Custom, viewType, extension, provider); + } + + public get(viewType: string): ProviderEntry | undefined { + return this._providers.get(viewType); + } + + private add(type: WebviewEditorType, viewType: string, extension: IExtensionDescription, provider: vscode.CustomTextEditorProvider | vscode.CustomReadonlyEditorProvider): vscode.Disposable { + if (this._providers.has(viewType)) { + throw new Error(`Provider for viewType:${viewType} already registered`); + } + this._providers.set(viewType, { type, extension, provider } as ProviderEntry); + return new extHostTypes.Disposable(() => this._providers.delete(viewType)); + } +} + +export class ExtHostCustomEditors implements extHostProtocol.ExtHostCustomEditorsShape { + + private readonly _proxy: extHostProtocol.MainThreadWebviewsShape; + + private readonly _editorProviders = new EditorProviderStore(); + + private readonly _documents = new CustomDocumentStore(); + + constructor( + mainContext: extHostProtocol.IMainContext, + private readonly _extHostDocuments: ExtHostDocuments, + private readonly _extensionStoragePaths: IExtensionStoragePaths | undefined, + private readonly _extHostWebview: ExtHostWebviews, + ) { + this._proxy = mainContext.getProxy(extHostProtocol.MainContext.MainThreadWebviews); + } + + public registerCustomEditorProvider( + extension: IExtensionDescription, + viewType: string, + provider: vscode.CustomReadonlyEditorProvider | vscode.CustomTextEditorProvider, + options: { webviewOptions?: vscode.WebviewPanelOptions, supportsMultipleEditorsPerDocument?: boolean }, + ): vscode.Disposable { + const disposables = new DisposableStore(); + if ('resolveCustomTextEditor' in provider) { + disposables.add(this._editorProviders.addTextProvider(viewType, extension, provider)); + this._proxy.$registerTextEditorProvider(toExtensionData(extension), viewType, options.webviewOptions || {}, { + supportsMove: !!provider.moveCustomTextEditor, + }); + } else { + disposables.add(this._editorProviders.addCustomProvider(viewType, extension, provider)); + + if (this.supportEditing(provider)) { + disposables.add(provider.onDidChangeCustomDocument(e => { + const entry = this.getCustomDocumentEntry(viewType, e.document.uri); + if (isEditEvent(e)) { + const editId = entry.addEdit(e); + this._proxy.$onDidEdit(e.document.uri, viewType, editId, e.label); + } else { + this._proxy.$onContentChange(e.document.uri, viewType); + } + })); + } + + this._proxy.$registerCustomEditorProvider(toExtensionData(extension), viewType, options.webviewOptions || {}, !!options.supportsMultipleEditorsPerDocument); + } + + return extHostTypes.Disposable.from( + disposables, + new extHostTypes.Disposable(() => { + this._proxy.$unregisterEditorProvider(viewType); + })); + } + + + async $createCustomDocument(resource: UriComponents, viewType: string, backupId: string | undefined, cancellation: CancellationToken) { + const entry = this._editorProviders.get(viewType); + if (!entry) { + throw new Error(`No provider found for '${viewType}'`); + } + + if (entry.type !== WebviewEditorType.Custom) { + throw new Error(`Invalid provide type for '${viewType}'`); + } + + const revivedResource = URI.revive(resource); + const document = await entry.provider.openCustomDocument(revivedResource, { backupId }, cancellation); + + let storageRoot: URI | undefined; + if (this.supportEditing(entry.provider) && this._extensionStoragePaths) { + storageRoot = this._extensionStoragePaths.workspaceValue(entry.extension) ?? this._extensionStoragePaths.globalValue(entry.extension); + } + this._documents.add(viewType, document, storageRoot); + + return { editable: this.supportEditing(entry.provider) }; + } + + async $disposeCustomDocument(resource: UriComponents, viewType: string): Promise { + const entry = this._editorProviders.get(viewType); + if (!entry) { + throw new Error(`No provider found for '${viewType}'`); + } + + if (entry.type !== WebviewEditorType.Custom) { + throw new Error(`Invalid provider type for '${viewType}'`); + } + + const revivedResource = URI.revive(resource); + const { document } = this.getCustomDocumentEntry(viewType, revivedResource); + this._documents.delete(viewType, document); + document.dispose(); + } + + async $resolveWebviewEditor( + resource: UriComponents, + handle: extHostProtocol.WebviewPanelHandle, + viewType: string, + title: string, + position: EditorViewColumn, + options: modes.IWebviewOptions & modes.IWebviewPanelOptions, + cancellation: CancellationToken, + ): Promise { + const entry = this._editorProviders.get(viewType); + if (!entry) { + throw new Error(`No provider found for '${viewType}'`); + } + + const webview = this._extHostWebview.createNewWebview(handle, options, entry.extension); + const panel = this._extHostWebview.createNewWebviewPanel(handle, viewType, title, position, options, webview); + + const revivedResource = URI.revive(resource); + + switch (entry.type) { + case WebviewEditorType.Custom: + { + const { document } = this.getCustomDocumentEntry(viewType, revivedResource); + return entry.provider.resolveCustomEditor(document, panel, cancellation); + } + case WebviewEditorType.Text: + { + const document = this._extHostDocuments.getDocument(revivedResource); + return entry.provider.resolveCustomTextEditor(document, panel, cancellation); + } + default: + { + throw new Error('Unknown webview provider type'); + } + } + } + + $disposeEdits(resourceComponents: UriComponents, viewType: string, editIds: number[]): void { + const document = this.getCustomDocumentEntry(viewType, resourceComponents); + document.disposeEdits(editIds); + } + + async $onMoveCustomEditor(handle: string, newResourceComponents: UriComponents, viewType: string): Promise { + const entry = this._editorProviders.get(viewType); + if (!entry) { + throw new Error(`No provider found for '${viewType}'`); + } + + if (!(entry.provider as vscode.CustomTextEditorProvider).moveCustomTextEditor) { + throw new Error(`Provider does not implement move '${viewType}'`); + } + + const webview = this._extHostWebview.getWebviewPanel(handle); + if (!webview) { + throw new Error(`No webview found`); + } + + const resource = URI.revive(newResourceComponents); + const document = this._extHostDocuments.getDocument(resource); + await (entry.provider as vscode.CustomTextEditorProvider).moveCustomTextEditor!(document, webview, CancellationToken.None); + } + + async $undo(resourceComponents: UriComponents, viewType: string, editId: number, isDirty: boolean): Promise { + const entry = this.getCustomDocumentEntry(viewType, resourceComponents); + return entry.undo(editId, isDirty); + } + + async $redo(resourceComponents: UriComponents, viewType: string, editId: number, isDirty: boolean): Promise { + const entry = this.getCustomDocumentEntry(viewType, resourceComponents); + return entry.redo(editId, isDirty); + } + + async $revert(resourceComponents: UriComponents, viewType: string, cancellation: CancellationToken): Promise { + const entry = this.getCustomDocumentEntry(viewType, resourceComponents); + const provider = this.getCustomEditorProvider(viewType); + await provider.revertCustomDocument(entry.document, cancellation); + entry.disposeBackup(); + } + + async $onSave(resourceComponents: UriComponents, viewType: string, cancellation: CancellationToken): Promise { + const entry = this.getCustomDocumentEntry(viewType, resourceComponents); + const provider = this.getCustomEditorProvider(viewType); + await provider.saveCustomDocument(entry.document, cancellation); + entry.disposeBackup(); + } + + async $onSaveAs(resourceComponents: UriComponents, viewType: string, targetResource: UriComponents, cancellation: CancellationToken): Promise { + const entry = this.getCustomDocumentEntry(viewType, resourceComponents); + const provider = this.getCustomEditorProvider(viewType); + return provider.saveCustomDocumentAs(entry.document, URI.revive(targetResource), cancellation); + } + + async $backup(resourceComponents: UriComponents, viewType: string, cancellation: CancellationToken): Promise { + const entry = this.getCustomDocumentEntry(viewType, resourceComponents); + const provider = this.getCustomEditorProvider(viewType); + + const backup = await provider.backupCustomDocument(entry.document, { + destination: entry.getNewBackupUri(), + }, cancellation); + entry.updateBackup(backup); + return backup.id; + } + + + private getCustomDocumentEntry(viewType: string, resource: UriComponents): CustomDocumentStoreEntry { + const entry = this._documents.get(viewType, URI.revive(resource)); + if (!entry) { + throw new Error('No custom document found'); + } + return entry; + } + + private getCustomEditorProvider(viewType: string): vscode.CustomEditorProvider { + const entry = this._editorProviders.get(viewType); + const provider = entry?.provider; + if (!provider || !this.supportEditing(provider)) { + throw new Error('Custom document is not editable'); + } + return provider; + } + + private supportEditing( + provider: vscode.CustomTextEditorProvider | vscode.CustomEditorProvider | vscode.CustomReadonlyEditorProvider + ): provider is vscode.CustomEditorProvider { + return !!(provider as vscode.CustomEditorProvider).onDidChangeCustomDocument; + } +} + + +function isEditEvent(e: vscode.CustomDocumentContentChangeEvent | vscode.CustomDocumentEditEvent): e is vscode.CustomDocumentEditEvent { + return typeof (e as vscode.CustomDocumentEditEvent).undo === 'function' + && typeof (e as vscode.CustomDocumentEditEvent).redo === 'function'; +} + +function hashPath(resource: URI): string { + const str = resource.scheme === Schemas.file || resource.scheme === Schemas.untitled ? resource.fsPath : resource.toString(); + return hash(str) + ''; +} + diff --git a/src/vs/workbench/api/common/extHostWebview.ts b/src/vs/workbench/api/common/extHostWebview.ts index 2e74c83c3cf..be7200fdb96 100644 --- a/src/vs/workbench/api/common/extHostWebview.ts +++ b/src/vs/workbench/api/common/extHostWebview.ts @@ -3,31 +3,22 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { CancellationToken } from 'vs/base/common/cancellation'; import { Emitter, Event } from 'vs/base/common/event'; -import { hash } from 'vs/base/common/hash'; -import { Disposable, DisposableStore } from 'vs/base/common/lifecycle'; -import { Schemas } from 'vs/base/common/network'; -import { joinPath } from 'vs/base/common/resources'; -import { URI, UriComponents } from 'vs/base/common/uri'; +import { Disposable } from 'vs/base/common/lifecycle'; +import { URI } from 'vs/base/common/uri'; import { generateUuid } from 'vs/base/common/uuid'; import * as modes from 'vs/editor/common/modes'; import { IExtensionDescription } from 'vs/platform/extensions/common/extensions'; import { ILogService } from 'vs/platform/log/common/log'; import { IExtHostApiDeprecationService } from 'vs/workbench/api/common/extHostApiDeprecationService'; -import { ExtHostDocuments } from 'vs/workbench/api/common/extHostDocuments'; -import { IExtensionStoragePaths } from 'vs/workbench/api/common/extHostStoragePaths'; import * as typeConverters from 'vs/workbench/api/common/extHostTypeConverters'; import { IExtHostWorkspace } from 'vs/workbench/api/common/extHostWorkspace'; import { EditorViewColumn } from 'vs/workbench/api/common/shared/editor'; import { asWebviewUri, WebviewInitData } from 'vs/workbench/api/common/shared/webview'; import type * as vscode from 'vscode'; -import { Cache } from './cache'; import * as extHostProtocol from './extHost.protocol'; import * as extHostTypes from './extHostTypes'; -type IconPath = URI | { light: URI, dark: URI }; - export class ExtHostWebview implements vscode.Webview { readonly #handle: extHostProtocol.WebviewPanelHandle; @@ -125,7 +116,10 @@ export class ExtHostWebview implements vscode.Webview { } } -export class ExtHostWebviewEditor extends Disposable implements vscode.WebviewPanel { +type IconPath = URI | { light: URI, dark: URI }; + + +class ExtHostWebviewPanel extends Disposable implements vscode.WebviewPanel { readonly #handle: extHostProtocol.WebviewPanelHandle; readonly #proxy: extHostProtocol.MainThreadWebviewsShape; @@ -274,137 +268,6 @@ export class ExtHostWebviewEditor extends Disposable implements vscode.WebviewPa } } -class CustomDocumentStoreEntry { - - private _backupCounter = 1; - - constructor( - public readonly document: vscode.CustomDocument, - private readonly _storagePath: URI | undefined, - ) { } - - private readonly _edits = new Cache('custom documents'); - - private _backup?: vscode.CustomDocumentBackup; - - addEdit(item: vscode.CustomDocumentEditEvent): number { - return this._edits.add([item]); - } - - async undo(editId: number, isDirty: boolean): Promise { - await this.getEdit(editId).undo(); - if (!isDirty) { - this.disposeBackup(); - } - } - - async redo(editId: number, isDirty: boolean): Promise { - await this.getEdit(editId).redo(); - if (!isDirty) { - this.disposeBackup(); - } - } - - disposeEdits(editIds: number[]): void { - for (const id of editIds) { - this._edits.delete(id); - } - } - - getNewBackupUri(): URI { - if (!this._storagePath) { - throw new Error('Backup requires a valid storage path'); - } - const fileName = hashPath(this.document.uri) + (this._backupCounter++); - return joinPath(this._storagePath, fileName); - } - - updateBackup(backup: vscode.CustomDocumentBackup): void { - this._backup?.delete(); - this._backup = backup; - } - - disposeBackup(): void { - this._backup?.delete(); - this._backup = undefined; - } - - private getEdit(editId: number): vscode.CustomDocumentEditEvent { - const edit = this._edits.get(editId, 0); - if (!edit) { - throw new Error('No edit found'); - } - return edit; - } -} - -class CustomDocumentStore { - private readonly _documents = new Map(); - - public get(viewType: string, resource: vscode.Uri): CustomDocumentStoreEntry | undefined { - return this._documents.get(this.key(viewType, resource)); - } - - public add(viewType: string, document: vscode.CustomDocument, storagePath: URI | undefined): CustomDocumentStoreEntry { - const key = this.key(viewType, document.uri); - if (this._documents.has(key)) { - throw new Error(`Document already exists for viewType:${viewType} resource:${document.uri}`); - } - const entry = new CustomDocumentStoreEntry(document, storagePath); - this._documents.set(key, entry); - return entry; - } - - public delete(viewType: string, document: vscode.CustomDocument) { - const key = this.key(viewType, document.uri); - this._documents.delete(key); - } - - private key(viewType: string, resource: vscode.Uri): string { - return `${viewType}@@@${resource}`; - } - -} - -const enum WebviewEditorType { - Text, - Custom -} - -type ProviderEntry = { - readonly extension: IExtensionDescription; - readonly type: WebviewEditorType.Text; - readonly provider: vscode.CustomTextEditorProvider; -} | { - readonly extension: IExtensionDescription; - readonly type: WebviewEditorType.Custom; - readonly provider: vscode.CustomReadonlyEditorProvider; -}; - -class EditorProviderStore { - private readonly _providers = new Map(); - - public addTextProvider(viewType: string, extension: IExtensionDescription, provider: vscode.CustomTextEditorProvider): vscode.Disposable { - return this.add(WebviewEditorType.Text, viewType, extension, provider); - } - - public addCustomProvider(viewType: string, extension: IExtensionDescription, provider: vscode.CustomReadonlyEditorProvider): vscode.Disposable { - return this.add(WebviewEditorType.Custom, viewType, extension, provider); - } - - public get(viewType: string): ProviderEntry | undefined { - return this._providers.get(viewType); - } - - private add(type: WebviewEditorType, viewType: string, extension: IExtensionDescription, provider: vscode.CustomTextEditorProvider | vscode.CustomReadonlyEditorProvider): vscode.Disposable { - if (this._providers.has(viewType)) { - throw new Error(`Provider for viewType:${viewType} already registered`); - } - this._providers.set(viewType, { type, extension, provider } as ProviderEntry); - return new extHostTypes.Disposable(() => this._providers.delete(viewType)); - } -} - export class ExtHostWebviews implements extHostProtocol.ExtHostWebviewsShape { private static newHandle(): extHostProtocol.WebviewPanelHandle { @@ -414,25 +277,19 @@ export class ExtHostWebviews implements extHostProtocol.ExtHostWebviewsShape { private readonly _proxy: extHostProtocol.MainThreadWebviewsShape; private readonly _webviews = new Map(); - private readonly _webviewPanels = new Map(); + private readonly _webviewPanels = new Map(); private readonly _serializers = new Map(); - private readonly _editorProviders = new EditorProviderStore(); - - private readonly _documents = new CustomDocumentStore(); - constructor( mainContext: extHostProtocol.IMainContext, private readonly initData: WebviewInitData, private readonly workspace: IExtHostWorkspace | undefined, private readonly _logService: ILogService, private readonly _deprecationService: IExtHostApiDeprecationService, - private readonly _extHostDocuments: ExtHostDocuments, - private readonly _extensionStoragePaths?: IExtensionStoragePaths, ) { this._proxy = mainContext.getProxy(extHostProtocol.MainContext.MainThreadWebviews); } @@ -454,8 +311,8 @@ export class ExtHostWebviews implements extHostProtocol.ExtHostWebviewsShape { this._proxy.$createWebviewPanel(toExtensionData(extension), handle, viewType, title, webviewShowOptions, convertWebviewOptions(extension, this.workspace, options)); const webview = this.createNewWebview(handle, options, extension); - const panel = new ExtHostWebviewEditor(handle, this._proxy, viewType, title, viewColumn, options, webview); - this._webviewPanels.set(handle, panel); + const panel = this.createNewWebviewPanel(handle, viewType, title, viewColumn, options, webview); + return panel; } @@ -477,43 +334,6 @@ export class ExtHostWebviews implements extHostProtocol.ExtHostWebviewsShape { }); } - public registerCustomEditorProvider( - extension: IExtensionDescription, - viewType: string, - provider: vscode.CustomReadonlyEditorProvider | vscode.CustomTextEditorProvider, - options: { webviewOptions?: vscode.WebviewPanelOptions, supportsMultipleEditorsPerDocument?: boolean }, - ): vscode.Disposable { - const disposables = new DisposableStore(); - if ('resolveCustomTextEditor' in provider) { - disposables.add(this._editorProviders.addTextProvider(viewType, extension, provider)); - this._proxy.$registerTextEditorProvider(toExtensionData(extension), viewType, options.webviewOptions || {}, { - supportsMove: !!provider.moveCustomTextEditor, - }); - } else { - disposables.add(this._editorProviders.addCustomProvider(viewType, extension, provider)); - - if (this.supportEditing(provider)) { - disposables.add(provider.onDidChangeCustomDocument(e => { - const entry = this.getCustomDocumentEntry(viewType, e.document.uri); - if (isEditEvent(e)) { - const editId = entry.addEdit(e); - this._proxy.$onDidEdit(e.document.uri, viewType, editId, e.label); - } else { - this._proxy.$onContentChange(e.document.uri, viewType); - } - })); - } - - this._proxy.$registerCustomEditorProvider(toExtensionData(extension), viewType, options.webviewOptions || {}, !!options.supportsMultipleEditorsPerDocument); - } - - return extHostTypes.Disposable.from( - disposables, - new extHostTypes.Disposable(() => { - this._proxy.$unregisterEditorProvider(viewType); - })); - } - public $onMessage( handle: extHostProtocol.WebviewPanelHandle, message: any @@ -587,151 +407,14 @@ export class ExtHostWebviews implements extHostProtocol.ExtHostWebviewsShape { const { serializer, extension } = entry; const webview = this.createNewWebview(webviewHandle, options, extension); - const revivedPanel = new ExtHostWebviewEditor(webviewHandle, this._proxy, viewType, title, typeof position === 'number' && position >= 0 ? typeConverters.ViewColumn.to(position) : undefined, options, webview); - this._webviewPanels.set(webviewHandle, revivedPanel); + const revivedPanel = this.createNewWebviewPanel(webviewHandle, viewType, title, position, options, webview); await serializer.deserializeWebviewPanel(revivedPanel, state); } - async $createCustomDocument(resource: UriComponents, viewType: string, backupId: string | undefined, cancellation: CancellationToken) { - const entry = this._editorProviders.get(viewType); - if (!entry) { - throw new Error(`No provider found for '${viewType}'`); - } - - if (entry.type !== WebviewEditorType.Custom) { - throw new Error(`Invalid provide type for '${viewType}'`); - } - - const revivedResource = URI.revive(resource); - const document = await entry.provider.openCustomDocument(revivedResource, { backupId }, cancellation); - - let storageRoot: URI | undefined; - if (this.supportEditing(entry.provider) && this._extensionStoragePaths) { - storageRoot = this._extensionStoragePaths.workspaceValue(entry.extension) ?? this._extensionStoragePaths.globalValue(entry.extension); - } - this._documents.add(viewType, document, storageRoot); - - return { editable: this.supportEditing(entry.provider) }; - } - - async $disposeCustomDocument(resource: UriComponents, viewType: string): Promise { - const entry = this._editorProviders.get(viewType); - if (!entry) { - throw new Error(`No provider found for '${viewType}'`); - } - - if (entry.type !== WebviewEditorType.Custom) { - throw new Error(`Invalid provider type for '${viewType}'`); - } - - const revivedResource = URI.revive(resource); - const { document } = this.getCustomDocumentEntry(viewType, revivedResource); - this._documents.delete(viewType, document); - document.dispose(); - } - - async $resolveWebviewEditor( - resource: UriComponents, - handle: extHostProtocol.WebviewPanelHandle, - viewType: string, - title: string, - position: EditorViewColumn, - options: modes.IWebviewOptions & modes.IWebviewPanelOptions, - cancellation: CancellationToken, - ): Promise { - const entry = this._editorProviders.get(viewType); - if (!entry) { - throw new Error(`No provider found for '${viewType}'`); - } - - const webview = this.createNewWebview(handle, options, entry.extension); - const revivedPanel = new ExtHostWebviewEditor(handle, this._proxy, viewType, title, typeof position === 'number' && position >= 0 ? typeConverters.ViewColumn.to(position) : undefined, options, webview); - this._webviewPanels.set(handle, revivedPanel); - - const revivedResource = URI.revive(resource); - - switch (entry.type) { - case WebviewEditorType.Custom: - { - const { document } = this.getCustomDocumentEntry(viewType, revivedResource); - return entry.provider.resolveCustomEditor(document, revivedPanel, cancellation); - } - case WebviewEditorType.Text: - { - const document = this._extHostDocuments.getDocument(revivedResource); - return entry.provider.resolveCustomTextEditor(document, revivedPanel, cancellation); - } - default: - { - throw new Error('Unknown webview provider type'); - } - } - } - - $disposeEdits(resourceComponents: UriComponents, viewType: string, editIds: number[]): void { - const document = this.getCustomDocumentEntry(viewType, resourceComponents); - document.disposeEdits(editIds); - } - - async $onMoveCustomEditor(handle: string, newResourceComponents: UriComponents, viewType: string): Promise { - const entry = this._editorProviders.get(viewType); - if (!entry) { - throw new Error(`No provider found for '${viewType}'`); - } - - if (!(entry.provider as vscode.CustomTextEditorProvider).moveCustomTextEditor) { - throw new Error(`Provider does not implement move '${viewType}'`); - } - - const webview = this.getWebviewPanel(handle); - if (!webview) { - throw new Error(`No webview found`); - } - - const resource = URI.revive(newResourceComponents); - const document = this._extHostDocuments.getDocument(resource); - await (entry.provider as vscode.CustomTextEditorProvider).moveCustomTextEditor!(document, webview, CancellationToken.None); - } - - async $undo(resourceComponents: UriComponents, viewType: string, editId: number, isDirty: boolean): Promise { - const entry = this.getCustomDocumentEntry(viewType, resourceComponents); - return entry.undo(editId, isDirty); - } - - async $redo(resourceComponents: UriComponents, viewType: string, editId: number, isDirty: boolean): Promise { - const entry = this.getCustomDocumentEntry(viewType, resourceComponents); - return entry.redo(editId, isDirty); - } - - async $revert(resourceComponents: UriComponents, viewType: string, cancellation: CancellationToken): Promise { - const entry = this.getCustomDocumentEntry(viewType, resourceComponents); - const provider = this.getCustomEditorProvider(viewType); - await provider.revertCustomDocument(entry.document, cancellation); - entry.disposeBackup(); - } - - async $onSave(resourceComponents: UriComponents, viewType: string, cancellation: CancellationToken): Promise { - const entry = this.getCustomDocumentEntry(viewType, resourceComponents); - const provider = this.getCustomEditorProvider(viewType); - await provider.saveCustomDocument(entry.document, cancellation); - entry.disposeBackup(); - } - - async $onSaveAs(resourceComponents: UriComponents, viewType: string, targetResource: UriComponents, cancellation: CancellationToken): Promise { - const entry = this.getCustomDocumentEntry(viewType, resourceComponents); - const provider = this.getCustomEditorProvider(viewType); - return provider.saveCustomDocumentAs(entry.document, URI.revive(targetResource), cancellation); - } - - async $backup(resourceComponents: UriComponents, viewType: string, cancellation: CancellationToken): Promise { - const entry = this.getCustomDocumentEntry(viewType, resourceComponents); - const provider = this.getCustomEditorProvider(viewType); - - const backup = await provider.backupCustomDocument(entry.document, { - destination: entry.getNewBackupUri(), - }, cancellation); - entry.updateBackup(backup); - return backup.id; + public createNewWebviewPanel(webviewHandle: string, viewType: string, title: string, position: number, options: modes.IWebviewOptions & modes.IWebviewPanelOptions, webview: ExtHostWebview) { + const panel = new ExtHostWebviewPanel(webviewHandle, this._proxy, viewType, title, typeof position === 'number' && position >= 0 ? typeConverters.ViewColumn.to(position) : undefined, options, webview); + this._webviewPanels.set(webviewHandle, panel); + return panel; } public createNewWebview(handle: string, options: modes.IWebviewOptions & modes.IWebviewPanelOptions, extension: IExtensionDescription): ExtHostWebview { @@ -747,35 +430,12 @@ export class ExtHostWebviews implements extHostProtocol.ExtHostWebviewsShape { return this._webviews.get(handle); } - private getWebviewPanel(handle: extHostProtocol.WebviewPanelHandle): ExtHostWebviewEditor | undefined { + public getWebviewPanel(handle: extHostProtocol.WebviewPanelHandle): ExtHostWebviewPanel | undefined { return this._webviewPanels.get(handle); } - - private getCustomDocumentEntry(viewType: string, resource: UriComponents): CustomDocumentStoreEntry { - const entry = this._documents.get(viewType, URI.revive(resource)); - if (!entry) { - throw new Error('No custom document found'); - } - return entry; - } - - private getCustomEditorProvider(viewType: string): vscode.CustomEditorProvider { - const entry = this._editorProviders.get(viewType); - const provider = entry?.provider; - if (!provider || !this.supportEditing(provider)) { - throw new Error('Custom document is not editable'); - } - return provider; - } - - private supportEditing( - provider: vscode.CustomTextEditorProvider | vscode.CustomEditorProvider | vscode.CustomReadonlyEditorProvider - ): provider is vscode.CustomEditorProvider { - return !!(provider as vscode.CustomEditorProvider).onDidChangeCustomDocument; - } } -function toExtensionData(extension: IExtensionDescription): extHostProtocol.WebviewExtensionDescription { +export function toExtensionData(extension: IExtensionDescription): extHostProtocol.WebviewExtensionDescription { return { id: extension.identifier, location: extension.extensionLocation }; } @@ -808,13 +468,3 @@ function getDefaultLocalResourceRoots( extension.extensionLocation, ]; } - -function isEditEvent(e: vscode.CustomDocumentContentChangeEvent | vscode.CustomDocumentEditEvent): e is vscode.CustomDocumentEditEvent { - return typeof (e as vscode.CustomDocumentEditEvent).undo === 'function' - && typeof (e as vscode.CustomDocumentEditEvent).redo === 'function'; -} - -function hashPath(resource: URI): string { - const str = resource.scheme === Schemas.file || resource.scheme === Schemas.untitled ? resource.fsPath : resource.toString(); - return hash(str) + ''; -} diff --git a/src/vs/workbench/api/common/extHostWebviewView.ts b/src/vs/workbench/api/common/extHostWebviewView.ts index e5bb7094372..f8162cc0669 100644 --- a/src/vs/workbench/api/common/extHostWebviewView.ts +++ b/src/vs/workbench/api/common/extHostWebviewView.ts @@ -169,7 +169,7 @@ export class ExtHostWebviewViews implements extHostProtocol.ExtHostWebviewViewsS private getWebviewView(handle: string): ExtHostWebviewView { const entry = this._webviewViews.get(handle); if (!entry) { - throw new Error('Custom document is not editable'); + throw new Error('No webview found'); } return entry; } diff --git a/src/vs/workbench/test/browser/api/extHostWebview.test.ts b/src/vs/workbench/test/browser/api/extHostWebview.test.ts index f74b2998d27..4e2046729d6 100644 --- a/src/vs/workbench/test/browser/api/extHostWebview.test.ts +++ b/src/vs/workbench/test/browser/api/extHostWebview.test.ts @@ -3,33 +3,27 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type * as vscode from 'vscode'; import * as assert from 'assert'; import { URI } from 'vs/base/common/uri'; +import { mock } from 'vs/base/test/common/mock'; import { IExtensionDescription } from 'vs/platform/extensions/common/extensions'; import { NullLogService } from 'vs/platform/log/common/log'; import { MainThreadWebviews } from 'vs/workbench/api/browser/mainThreadWebview'; -import { ExtHostWebviews } from 'vs/workbench/api/common/extHostWebview'; -import { EditorViewColumn } from 'vs/workbench/api/common/shared/editor'; -import { mock } from 'vs/base/test/common/mock'; -import { SingleProxyRPCProtocol } from './testRPCProtocol'; -import { ExtHostDocumentsAndEditors } from 'vs/workbench/api/common/extHostDocumentsAndEditors'; -import { ExtHostDocuments } from 'vs/workbench/api/common/extHostDocuments'; -import { IExtHostRpcService } from 'vs/workbench/api/common/extHostRpcService'; import { IExtHostContext } from 'vs/workbench/api/common/extHost.protocol'; import { NullApiDeprecationService } from 'vs/workbench/api/common/extHostApiDeprecationService'; +import { IExtHostRpcService } from 'vs/workbench/api/common/extHostRpcService'; +import { ExtHostWebviews } from 'vs/workbench/api/common/extHostWebview'; +import { EditorViewColumn } from 'vs/workbench/api/common/shared/editor'; +import type * as vscode from 'vscode'; +import { SingleProxyRPCProtocol } from './testRPCProtocol'; suite('ExtHostWebview', () => { let rpcProtocol: (IExtHostRpcService & IExtHostContext) | undefined; - let extHostDocuments: ExtHostDocuments | undefined; setup(() => { const shape = createNoopMainThreadWebviews(); rpcProtocol = SingleProxyRPCProtocol(shape); - - const extHostDocumentsAndEditors = new ExtHostDocumentsAndEditors(rpcProtocol, new NullLogService()); - extHostDocuments = new ExtHostDocuments(rpcProtocol, extHostDocumentsAndEditors); }); test('Cannot register multiple serializers for the same view type', async () => { @@ -39,7 +33,7 @@ suite('ExtHostWebview', () => { webviewCspSource: '', webviewResourceRoot: '', isExtensionDevelopmentDebug: false, - }, undefined, new NullLogService(), NullApiDeprecationService, extHostDocuments!); + }, undefined, new NullLogService(), NullApiDeprecationService); let lastInvokedDeserializer: vscode.WebviewPanelSerializer | undefined = undefined; @@ -76,7 +70,7 @@ suite('ExtHostWebview', () => { webviewCspSource: '', webviewResourceRoot: 'vscode-resource://{{resource}}', isExtensionDevelopmentDebug: false, - }, undefined, new NullLogService(), NullApiDeprecationService, extHostDocuments!); + }, undefined, new NullLogService(), NullApiDeprecationService); const webview = extHostWebviews.createWebviewPanel({} as any, 'type', 'title', 1, {}); assert.strictEqual( @@ -115,7 +109,7 @@ suite('ExtHostWebview', () => { webviewCspSource: '', webviewResourceRoot: `https://{{uuid}}.webview.contoso.com/commit/{{resource}}`, isExtensionDevelopmentDebug: false, - }, undefined, new NullLogService(), NullApiDeprecationService, extHostDocuments!); + }, undefined, new NullLogService(), NullApiDeprecationService); const webview = extHostWebviews.createWebviewPanel({} as any, 'type', 'title', 1, {}); function stripEndpointUuid(input: string) {