From 61f799f53bc11b3f28ace31dc77a335ca447ac3a Mon Sep 17 00:00:00 2001 From: Matt Bierner Date: Thu, 20 Aug 2020 13:59:22 -0700 Subject: [PATCH] Add proposed webview view API (#104601) Add proposed webview view API For #46585 This adds a new `WebviewView` proposed api to VS Code that lets webview be used inside views. Webview views can be contributed using a contribution point such as : ```json "views": { "explorer": [ { "type": "webview", "id": "cats.cat", "name": "Cats", "visibility": "visible" } ] }, ``` * Use proper activation event * Transparent background * Fix resize observer * Adding documentation * Move webview view to new directory under workbench * Remove resolver By moving the webviews view into their own fodler, I was able to avoid the cycle the resolver was originally introduced for * Use enum in more places * Hook up title and visible properties for webview views * Remove test view * Prefer Thenable * Add unknown view type error to collector --- extensions/image-preview/src/extension.ts | 2 +- src/vs/vscode.proposed.d.ts | 137 +++++++++++++++ .../api/browser/mainThreadWebview.ts | 87 ++++++++-- .../api/browser/viewsExtensionPoint.ts | 43 ++++- .../workbench/api/common/extHost.api.impl.ts | 8 + .../workbench/api/common/extHost.protocol.ts | 8 + src/vs/workbench/api/common/extHostWebview.ts | 156 ++++++++++++++++++ src/vs/workbench/common/views.ts | 2 + .../browser/webviewView.contribution.ts | 9 + .../webviewView/browser/webviewViewPane.ts | 153 +++++++++++++++++ .../webviewView/browser/webviewViewService.ts | 84 ++++++++++ src/vs/workbench/workbench.common.main.ts | 1 + 12 files changed, 674 insertions(+), 16 deletions(-) create mode 100644 src/vs/workbench/contrib/webviewView/browser/webviewView.contribution.ts create mode 100644 src/vs/workbench/contrib/webviewView/browser/webviewViewPane.ts create mode 100644 src/vs/workbench/contrib/webviewView/browser/webviewViewService.ts diff --git a/extensions/image-preview/src/extension.ts b/extensions/image-preview/src/extension.ts index 552b32d39b6..10722360dd5 100644 --- a/extensions/image-preview/src/extension.ts +++ b/extensions/image-preview/src/extension.ts @@ -4,9 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { BinarySizeStatusBarEntry } from './binarySizeStatusBarEntry'; import { PreviewManager } from './preview'; import { SizeStatusBarEntry } from './sizeStatusBarEntry'; -import { BinarySizeStatusBarEntry } from './binarySizeStatusBarEntry'; import { ZoomStatusBarEntry } from './zoomStatusBarEntry'; export function activate(context: vscode.ExtensionContext) { diff --git a/src/vs/vscode.proposed.d.ts b/src/vs/vscode.proposed.d.ts index cab19daf4c1..1b98220afd5 100644 --- a/src/vs/vscode.proposed.d.ts +++ b/src/vs/vscode.proposed.d.ts @@ -1971,4 +1971,141 @@ declare module 'vscode' { notebook: NotebookDocument | undefined; } //#endregion + + + //#region https://github.com/microsoft/vscode/issues/46585 + + /** + * A webview based view. + */ + export interface WebviewView { + /** + * Identifies the type of the webview view, such as `'hexEditor.dataView'`. + */ + readonly viewType: string; + + /** + * The underlying webview for the view. + */ + readonly webview: Webview; + + /** + * View title displayed in the UI. + * + * The view title is initially taken from the extension `package.json` contribution. + */ + title?: string; + + /** + * Event fired when the view is disposed. + * + * Views are disposed of in a few cases: + * + * - When a view is collapsed and `retainContextWhenHidden` has not been set. + * - When a view is hidden by a user. + * + * Trying to use the view after it has been disposed throws an exception. + */ + readonly onDidDispose: Event; + + /** + * Tracks if the webview is currently visible. + * + * Views are visible when they are on the screen and expanded. + */ + readonly visible: boolean; + + /** + * Event fired when the visibility of the view changes + */ + readonly onDidChangeVisibility: Event; + } + + interface WebviewViewResolveContext { + /** + * Persisted state from the webview content. + * + * To save resources, VS Code normally deallocates webview views that are not visible. For example, if the user + * collapse a view or switching to another top level activity, the underlying webview document is deallocates. + * + * You can prevent this behavior by setting `retainContextWhenHidden` in the `WebviewOptions`. However this + * increases resource usage and should be avoided wherever possible. Instead, you can use persisted state to + * save off a webview's state so that it can be quickly recreated as needed. + * + * To save off a persisted state, inside the webview call `acquireVsCodeApi().setState()` with + * any json serializable object. To restore the state again, call `getState()`. For example: + * + * ```js + * // Within the webview + * const vscode = acquireVsCodeApi(); + * + * // Get existing state + * const oldState = vscode.getState() || { value: 0 }; + * + * // Update state + * setState({ value: oldState.value + 1 }) + * ``` + * + * VS Code ensures that the persisted state is saved correctly when a webview is hidden and across + * editor restarts. + */ + readonly state: T | undefined; + } + + /** + * Provider for creating `WebviewView` elements. + */ + export interface WebviewViewProvider { + /** + * Revolves a webview view. + * + * `resolveWebviewView` is called when a view first becomes visible. This may happen when the view is + * first loaded or when the user hides and then shows a view again. + * + * @param webviewView Webview panel to restore. The serializer should take ownership of this panel. The + * provider must set the webview's `.html` and hook up all webview events it is interested in. + * @param context Additional metadata about the view being resolved. + * @param token Cancellation token indicating that the view being provided is no longer needed. + * + * @return Optional thenable indicating that the view has been fully resolved. + */ + resolveWebviewView(webviewView: WebviewView, context: WebviewViewResolveContext, token: CancellationToken): Thenable | void; + } + + namespace window { + /** + * Register a new provider for webview views. + * + * @param viewId Unique id of the view. This should match the `id` from the + * `views` contribution in the package.json. + * @param provider Provider for the webview views. + * + * @return Disposable that unregisters the provider. + */ + export function registerWebviewViewProvider(viewId: string, provider: WebviewViewProvider, options?: { + /** + * Content settings for the webview created for this view. + */ + readonly webviewOptions?: { + /** + * Controls if the webview panel's content (iframe) is kept around even when the panel + * is no longer visible. + * + * Normally the webview's html context is created when the panel becomes visible + * and destroyed when it is hidden. Extensions that have complex state + * or UI can set the `retainContextWhenHidden` to make VS Code keep the webview + * context around, even when the webview moves to a background tab. When a webview using + * `retainContextWhenHidden` becomes hidden, its scripts and other dynamic content are suspended. + * When the panel becomes visible again, the context is automatically restored + * in the exact same state it was in originally. You cannot send messages to a + * hidden webview, even with `retainContextWhenHidden` enabled. + * + * `retainContextWhenHidden` has a high memory overhead and should only be used if + * your panel's context cannot be quickly saved and restored. + */ + readonly retainContextWhenHidden?: boolean; + }; + }): Disposable; + } + //#endregion } diff --git a/src/vs/workbench/api/browser/mainThreadWebview.ts b/src/vs/workbench/api/browser/mainThreadWebview.ts index 0d21c18efe8..be1f4ed4cbc 100644 --- a/src/vs/workbench/api/browser/mainThreadWebview.ts +++ b/src/vs/workbench/api/browser/mainThreadWebview.ts @@ -33,9 +33,10 @@ import { CustomEditorInput } from 'vs/workbench/contrib/customEditor/browser/cus import { CustomDocumentBackupData } from 'vs/workbench/contrib/customEditor/browser/customEditorInputFactory'; import { ICustomEditorModel, ICustomEditorService } from 'vs/workbench/contrib/customEditor/common/customEditor'; import { CustomTextEditorModel } from 'vs/workbench/contrib/customEditor/common/customTextEditorModel'; -import { WebviewExtensionDescription, WebviewIcons } from 'vs/workbench/contrib/webview/browser/webview'; +import { Webview, WebviewExtensionDescription, WebviewIcons, WebviewOverlay } from 'vs/workbench/contrib/webview/browser/webview'; import { WebviewInput } from 'vs/workbench/contrib/webview/browser/webviewEditorInput'; import { ICreateWebViewShowOptions, IWebviewWorkbenchService, WebviewInputOptions } from 'vs/workbench/contrib/webview/browser/webviewWorkbenchService'; +import { IWebviewViewService, WebviewView } from 'vs/workbench/contrib/webviewView/browser/webviewViewService'; import { IBackupFileService } from 'vs/workbench/services/backup/common/backup'; import { IEditorGroup, IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService'; import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; @@ -119,6 +120,10 @@ export class MainThreadWebviews extends Disposable implements extHostProtocol.Ma private readonly _proxy: extHostProtocol.ExtHostWebviewsShape; private readonly _webviewInputs = new WebviewInputStore(); private readonly _revivers = new Map(); + + private readonly _webviewViewProviders = new Map(); + private readonly _webviewViews = new Map(); + private readonly _editorProviders = new Map(); private readonly _webviewFromDiffEditorHandles = new Set(); @@ -136,6 +141,7 @@ export class MainThreadWebviews extends Disposable implements extHostProtocol.Ma @IWebviewWorkbenchService private readonly _webviewWorkbenchService: IWebviewWorkbenchService, @IInstantiationService private readonly _instantiationService: IInstantiationService, @IBackupFileService private readonly _backupService: IBackupFileService, + @IWebviewViewService private readonly _webviewViewService: IWebviewViewService, ) { super(); @@ -212,7 +218,7 @@ export class MainThreadWebviews extends Disposable implements extHostProtocol.Ma const extension = reviveWebviewExtension(extensionData); const webview = this._webviewWorkbenchService.createWebview(handle, webviewPanelViewType.fromExternal(viewType), title, mainThreadShowOptions, reviveWebviewOptions(options), extension); - this.hookupWebviewEventDelegate(handle, webview); + this.hookupWebviewEventDelegate(handle, webview.webview); this._webviewInputs.add(handle, webview); @@ -234,14 +240,22 @@ export class MainThreadWebviews extends Disposable implements extHostProtocol.Ma webview.setName(value); } + public $setWebviewViewTitle(handle: extHostProtocol.WebviewPanelHandle, value: string | undefined): void { + const webviewView = this._webviewViews.get(handle); + if (!webviewView) { + throw new Error('unknown webview view'); + } + webviewView.title = value; + } + public $setIconPath(handle: extHostProtocol.WebviewPanelHandle, value: { light: UriComponents, dark: UriComponents; } | undefined): void { const webview = this.getWebviewInput(handle); webview.iconPath = reviveWebviewIcon(value); } public $setHtml(handle: extHostProtocol.WebviewPanelHandle, value: string): void { - const webview = this.getWebviewInput(handle); - webview.webview.html = value; + const webview = this.getWebview(handle); + webview.html = value; } public $setOptions(handle: extHostProtocol.WebviewPanelHandle, options: modes.IWebviewOptions): void { @@ -285,7 +299,7 @@ export class MainThreadWebviews extends Disposable implements extHostProtocol.Ma const handle = webviewInput.id; this._webviewInputs.add(handle, webviewInput); - this.hookupWebviewEventDelegate(handle, webviewInput); + this.hookupWebviewEventDelegate(handle, webviewInput.webview); let state = undefined; if (webviewInput.webview.state) { @@ -316,6 +330,49 @@ export class MainThreadWebviews extends Disposable implements extHostProtocol.Ma this._revivers.delete(viewType); } + public $registerWebviewViewProvider(viewType: string, options?: { retainContextWhenHidden?: boolean }): void { + if (this._webviewViewProviders.has(viewType)) { + throw new Error(`View provider for ${viewType} already registered`); + } + + this._webviewViewService.register(viewType, { + resolve: async (webviewView: WebviewView, cancellation: CancellationToken) => { + this._webviewViews.set(viewType, webviewView); + + const handle = viewType; + this.hookupWebviewEventDelegate(handle, webviewView.webview); + + let state = undefined; + if (webviewView.webview.state) { + try { + state = JSON.parse(webviewView.webview.state); + } catch (e) { + console.error('Could not load webview state', e, webviewView.webview.state); + } + } + + if (options) { + webviewView.webview.options = options; + } + + webviewView.onDidChangeVisibility(visible => { + this._proxy.$onDidChangeWebviewViewVisibility(handle, visible); + }); + + webviewView.onDispose(() => { + this._proxy.$disposeWebviewView(handle); + }); + + try { + await this._proxy.$resolveWebviewView(handle, viewType, state, cancellation); + } catch (error) { + onUnexpectedError(error); + webviewView.webview.html = MainThreadWebviews.getWebviewResolvedFailedContent(viewType); + } + } + }); + } + public $registerTextEditorProvider(extensionData: extHostProtocol.WebviewExtensionDescription, viewType: string, options: modes.IWebviewPanelOptions, capabilities: extHostProtocol.CustomTextEditorCapabilities): void { this.registerEditorProvider(ModelType.Text, extensionData, viewType, options, capabilities, true); } @@ -353,7 +410,7 @@ export class MainThreadWebviews extends Disposable implements extHostProtocol.Ma const resource = webviewInput.resource; this._webviewInputs.add(handle, webviewInput); - this.hookupWebviewEventDelegate(handle, webviewInput); + this.hookupWebviewEventDelegate(handle, webviewInput.webview); webviewInput.webview.options = options; webviewInput.webview.extension = extension; @@ -460,14 +517,14 @@ export class MainThreadWebviews extends Disposable implements extHostProtocol.Ma model.changeContent(); } - private hookupWebviewEventDelegate(handle: extHostProtocol.WebviewPanelHandle, input: WebviewInput) { + private hookupWebviewEventDelegate(handle: extHostProtocol.WebviewPanelHandle, webview: WebviewOverlay) { const disposables = new DisposableStore(); - disposables.add(input.webview.onDidClickLink((uri) => this.onDidClickLink(handle, uri))); - disposables.add(input.webview.onMessage((message: any) => { this._proxy.$onMessage(handle, message); })); - disposables.add(input.webview.onMissingCsp((extension: ExtensionIdentifier) => this._proxy.$onMissingCsp(handle, extension.value))); + disposables.add(webview.onDidClickLink((uri) => this.onDidClickLink(handle, uri))); + disposables.add(webview.onMessage((message: any) => { this._proxy.$onMessage(handle, message); })); + disposables.add(webview.onMissingCsp((extension: ExtensionIdentifier) => this._proxy.$onMissingCsp(handle, extension.value))); - disposables.add(input.webview.onDispose(() => { + disposables.add(webview.onDispose(() => { disposables.dispose(); this._proxy.$onDidDisposeWebviewPanel(handle).finally(() => { @@ -554,6 +611,14 @@ export class MainThreadWebviews extends Disposable implements extHostProtocol.Ma return !!webview.webview.contentOptions.enableCommandUris && link.scheme === Schemas.command; } + private getWebview(handle: extHostProtocol.WebviewPanelHandle): Webview { + const webview = this.tryGetWebviewInput(handle)?.webview ?? this._webviewViews.get(handle)?.webview; + if (!webview) { + throw new Error(`Unknown webview handle:${handle}`); + } + return webview; + } + private getWebviewInput(handle: extHostProtocol.WebviewPanelHandle): WebviewInput { const webview = this.tryGetWebviewInput(handle); if (!webview) { diff --git a/src/vs/workbench/api/browser/viewsExtensionPoint.ts b/src/vs/workbench/api/browser/viewsExtensionPoint.ts index 25dadd40aab..73ca03673bc 100644 --- a/src/vs/workbench/api/browser/viewsExtensionPoint.ts +++ b/src/vs/workbench/api/browser/viewsExtensionPoint.ts @@ -32,6 +32,7 @@ import { ViewPaneContainer } from 'vs/workbench/browser/parts/views/viewPaneCont import { SyncDescriptor } from 'vs/platform/instantiation/common/descriptors'; import { Codicon } from 'vs/base/common/codicons'; import { CustomTreeView } from 'vs/workbench/contrib/views/browser/treeView'; +import { WebviewViewPane } from 'vs/workbench/contrib/webviewView/browser/webviewViewPane'; export interface IUserFriendlyViewsContainerDescriptor { id: string; @@ -76,7 +77,15 @@ export const viewsContainersContribution: IJSONSchema = { } }; +enum ViewType { + Tree = 'tree', + Webview = 'webview' +} + + interface IUserFriendlyViewDescriptor { + type?: ViewType; + id: string; name: string; when?: string; @@ -208,11 +217,18 @@ const viewsContribution: IJSONSchema = { } }; -export interface ICustomViewDescriptor extends ITreeViewDescriptor { +export interface ICustomTreeViewDescriptor extends ITreeViewDescriptor { readonly extensionId: ExtensionIdentifier; readonly originalContainerId: string; } +export interface ICustomWebviewViewDescriptor extends IViewDescriptor { + readonly extensionId: ExtensionIdentifier; + readonly originalContainerId: string; +} + +export type ICustomViewDescriptor = ICustomTreeViewDescriptor | ICustomWebviewViewDescriptor; + type ViewContainerExtensionPointType = { [loc: string]: IUserFriendlyViewsContainerDescriptor[] }; const viewsContainersExtensionPoint: IExtensionPoint = ExtensionsRegistry.registerExtensionPoint({ extensionPoint: 'viewsContainers', @@ -442,16 +458,24 @@ class ViewsExtensionHandler implements IWorkbenchContribution { const icon = item.icon ? resources.joinPath(extension.description.extensionLocation, item.icon) : undefined; const initialVisibility = this.convertInitialVisibility(item.visibility); - const viewDescriptor = { + + const type = this.getViewType(item.type); + if (!type) { + collector.error(localize('unknownViewType', "Unknown view type `{0}`.", item.type)); + return null; + } + + const viewDescriptor = { + type: type, + ctorDescriptor: type === ViewType.Tree ? new SyncDescriptor(TreeViewPane) : new SyncDescriptor(WebviewViewPane), id: item.id, name: item.name, - ctorDescriptor: new SyncDescriptor(TreeViewPane), when: ContextKeyExpr.deserialize(item.when), containerIcon: icon || viewContainer?.icon, containerTitle: item.contextualTitle || viewContainer?.name, canToggleVisibility: true, canMoveView: true, - treeView: this.instantiationService.createInstance(CustomTreeView, item.id, item.name), + treeView: type === ViewType.Tree ? this.instantiationService.createInstance(CustomTreeView, item.id, item.name) : undefined, collapsed: this.showCollapsed(container) || initialVisibility === InitialVisibility.Collapsed, order: order, extensionId: extension.description.identifier, @@ -461,6 +485,7 @@ class ViewsExtensionHandler implements IWorkbenchContribution { hideByDefault: initialVisibility === InitialVisibility.Hidden }; + viewIds.add(viewDescriptor.id); return viewDescriptor; })); @@ -473,6 +498,16 @@ class ViewsExtensionHandler implements IWorkbenchContribution { this.viewsRegistry.registerViews2(allViewDescriptors); } + private getViewType(type: string | undefined): ViewType | undefined { + if (type === ViewType.Webview) { + return ViewType.Webview; + } + if (!type || type === ViewType.Tree) { + return ViewType.Tree; + } + return undefined; + } + private getDefaultViewContainer(): ViewContainer { return this.viewContainersRegistry.get(EXPLORER)!; } diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 314ef18f902..25aa14724b4 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -612,6 +612,14 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I }, onDidChangeActiveColorTheme(listener, thisArg?, disposables?) { return extHostTheming.onDidChangeActiveColorTheme(listener, thisArg, disposables); + }, + registerWebviewViewProvider(viewId: string, provider: vscode.WebviewViewProvider, options?: { + webviewOptions?: { + retainContextWhenHidden?: boolean + } + }) { + checkProposedApiEnabled(extension); + return extHostWebviews.registerWebviewViewProvider(extension, viewId, provider, options?.webviewOptions); } }; diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 391a80730a7..29c9ebd1f0f 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -629,6 +629,10 @@ export interface MainThreadWebviewsShape extends IDisposable { $onDidEdit(resource: UriComponents, viewType: string, editId: number, label: string | undefined): void; $onContentChange(resource: UriComponents, viewType: string): void; + + $registerWebviewViewProvider(viewType: string, options?: { retainContextWhenHidden?: boolean }): void; + + $setWebviewViewTitle(handle: WebviewPanelHandle, value: string | undefined): void; } export interface WebviewPanelViewStateData { @@ -662,6 +666,10 @@ export interface ExtHostWebviewsShape { $backup(resource: UriComponents, viewType: string, cancellation: CancellationToken): Promise; $onMoveCustomEditor(handle: WebviewPanelHandle, newResource: UriComponents, viewType: string): Promise; + + $resolveWebviewView(webviewHandle: WebviewPanelHandle, viewType: string, state: any, cancellation: CancellationToken): Promise; + $onDidChangeWebviewViewVisibility(webviewHandle: WebviewPanelHandle, visible: boolean): void; + $disposeWebviewView(webviewHandle: WebviewPanelHandle): void; } export enum CellKind { diff --git a/src/vs/workbench/api/common/extHostWebview.ts b/src/vs/workbench/api/common/extHostWebview.ts index f005de99781..cdeb6495e1f 100644 --- a/src/vs/workbench/api/common/extHostWebview.ts +++ b/src/vs/workbench/api/common/extHostWebview.ts @@ -267,6 +267,92 @@ export class ExtHostWebviewEditor extends Disposable implements vscode.WebviewPa } } +export class ExtHostWebviewView extends Disposable implements vscode.WebviewView { + + readonly #handle: extHostProtocol.WebviewPanelHandle; + readonly #proxy: extHostProtocol.MainThreadWebviewsShape; + + readonly #viewType: string; + readonly #webview: ExtHostWebview; + + #isDisposed = false; + #isVisible: boolean; + #title: string | undefined; + + constructor( + handle: extHostProtocol.WebviewPanelHandle, + proxy: extHostProtocol.MainThreadWebviewsShape, + viewType: string, + webview: ExtHostWebview, + isVisible: boolean, + ) { + super(); + + this.#viewType = viewType; + this.#handle = handle; + this.#proxy = proxy; + this.#webview = webview; + this.#isVisible = isVisible; + } + + public dispose() { + if (this.#isDisposed) { + return; + } + + this.#isDisposed = true; + this.#onDidDispose.fire(); + + super.dispose(); + } + + readonly #onDidChangeVisibility = this._register(new Emitter()); + public readonly onDidChangeVisibility = this.#onDidChangeVisibility.event; + + readonly #onDidDispose = this._register(new Emitter()); + public readonly onDidDispose = this.#onDidDispose.event; + + get title(): string | undefined { + this.assertNotDisposed(); + return this.#title; + } + + set title(value: string | undefined) { + this.assertNotDisposed(); + if (this.#title !== value) { + this.#title = value; + this.#proxy.$setWebviewViewTitle(this.#handle, value); + } + } + + get visible() { + return this.#isVisible; + } + + get webview() { + return this.#webview; + } + + get viewType(): string { + return this.#viewType; + } + + _setVisible(visible: boolean) { + if (visible === this.#isVisible) { + return; + } + + this.#isVisible = visible; + this.#onDidChangeVisibility.fire(); + } + + private assertNotDisposed() { + if (this.#isDisposed) { + throw new Error('Webview is disposed'); + } + } +} + class CustomDocumentStoreEntry { private _backupCounter = 1; @@ -412,7 +498,13 @@ export class ExtHostWebviews implements extHostProtocol.ExtHostWebviewsShape { readonly extension: IExtensionDescription; }>(); + private readonly _viewProviders = new Map(); + private readonly _editorProviders = new EditorProviderStore(); + private readonly _webviewViews = new Map(); private readonly _documents = new CustomDocumentStore(); @@ -468,6 +560,27 @@ export class ExtHostWebviews implements extHostProtocol.ExtHostWebviewsShape { }); } + public registerWebviewViewProvider( + extension: IExtensionDescription, + viewType: string, + provider: vscode.WebviewViewProvider, + webviewOptions?: { + retainContextWhenHidden?: boolean + }, + ): vscode.Disposable { + if (this._viewProviders.has(viewType)) { + throw new Error(`View provider for '${viewType}' already registered`); + } + + this._viewProviders.set(viewType, { provider, extension }); + this._proxy.$registerWebviewViewProvider(viewType, webviewOptions); + + return new extHostTypes.Disposable(() => { + this._viewProviders.delete(viewType); + this._proxy.$unregisterSerializer(viewType); + }); + } + public registerCustomEditorProvider( extension: IExtensionDescription, viewType: string, @@ -583,6 +696,41 @@ export class ExtHostWebviews implements extHostProtocol.ExtHostWebviewsShape { await serializer.deserializeWebviewPanel(revivedPanel, state); } + async $resolveWebviewView( + webviewHandle: string, + viewType: string, + state: any, + cancellation: CancellationToken, + ): Promise { + const entry = this._viewProviders.get(viewType); + if (!entry) { + throw new Error(`No view provider found for '${viewType}'`); + } + + const { provider, extension } = entry; + + const webview = new ExtHostWebview(webviewHandle, this._proxy, reviveOptions({ /* todo */ }), this.initData, this.workspace, extension, this._deprecationService); + const revivedView = new ExtHostWebviewView(webviewHandle, this._proxy, viewType, webview, true); + + this._webviewViews.set(webviewHandle, revivedView); + + await provider.resolveWebviewView(revivedView, { state }, cancellation); + } + + async $onDidChangeWebviewViewVisibility( + webviewHandle: string, + visible: boolean + ) { + const webviewView = this.getWebviewView(webviewHandle); + webviewView._setVisible(visible); + } + + async $disposeWebviewView(webviewHandle: string) { + const webviewView = this.getWebviewView(webviewHandle); + this._webviewViews.delete(webviewHandle); + webviewView.dispose(); + } + async $createCustomDocument(resource: UriComponents, viewType: string, backupId: string | undefined, cancellation: CancellationToken) { const entry = this._editorProviders.get(viewType); if (!entry) { @@ -746,6 +894,14 @@ export class ExtHostWebviews implements extHostProtocol.ExtHostWebviewsShape { return provider; } + private getWebviewView(handle: string): ExtHostWebviewView { + const entry = this._webviewViews.get(handle); + if (!entry) { + throw new Error('Custom document is not editable'); + } + return entry; + } + private supportEditing( provider: vscode.CustomTextEditorProvider | vscode.CustomEditorProvider | vscode.CustomReadonlyEditorProvider ): provider is vscode.CustomEditorProvider { diff --git a/src/vs/workbench/common/views.ts b/src/vs/workbench/common/views.ts index 3d327ef6f64..e50bd99f313 100644 --- a/src/vs/workbench/common/views.ts +++ b/src/vs/workbench/common/views.ts @@ -196,6 +196,8 @@ Registry.add(Extensions.ViewContainersRegistry, new ViewContainersRegistryImpl() export interface IViewDescriptor { + readonly type?: string; + readonly id: string; readonly name: string; diff --git a/src/vs/workbench/contrib/webviewView/browser/webviewView.contribution.ts b/src/vs/workbench/contrib/webviewView/browser/webviewView.contribution.ts new file mode 100644 index 00000000000..9dd05114160 --- /dev/null +++ b/src/vs/workbench/contrib/webviewView/browser/webviewView.contribution.ts @@ -0,0 +1,9 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { registerSingleton } from 'vs/platform/instantiation/common/extensions'; +import { IWebviewViewService, WebviewViewService } from 'vs/workbench/contrib/webviewView/browser/webviewViewService'; + +registerSingleton(IWebviewViewService, WebviewViewService, true); diff --git a/src/vs/workbench/contrib/webviewView/browser/webviewViewPane.ts b/src/vs/workbench/contrib/webviewView/browser/webviewViewPane.ts new file mode 100644 index 00000000000..d1c1d95b238 --- /dev/null +++ b/src/vs/workbench/contrib/webviewView/browser/webviewViewPane.ts @@ -0,0 +1,153 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationTokenSource } from 'vs/base/common/cancellation'; +import { Emitter } from 'vs/base/common/event'; +import { toDisposable } from 'vs/base/common/lifecycle'; +import { setImmediate } from 'vs/base/common/platform'; +import { generateUuid } from 'vs/base/common/uuid'; +import { MenuId } from 'vs/platform/actions/common/actions'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; +import { IOpenerService } from 'vs/platform/opener/common/opener'; +import { IProgressService } from 'vs/platform/progress/common/progress'; +import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; +import { IThemeService } from 'vs/platform/theme/common/themeService'; +import { ViewPane } from 'vs/workbench/browser/parts/views/viewPaneContainer'; +import { IViewletViewOptions } from 'vs/workbench/browser/parts/views/viewsViewlet'; +import { IViewDescriptorService } from 'vs/workbench/common/views'; +import { IWebviewService, WebviewOverlay } from 'vs/workbench/contrib/webview/browser/webview'; +import { IWebviewViewService } from 'vs/workbench/contrib/webviewView/browser/webviewViewService'; +import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; + + +declare const ResizeObserver: any; + +export class WebviewViewPane extends ViewPane { + + private _webview?: WebviewOverlay; + private _activated = false; + + private _container?: HTMLElement; + private _resizeObserver?: any; + + constructor( + options: IViewletViewOptions, + @IKeybindingService keybindingService: IKeybindingService, + @IContextMenuService contextMenuService: IContextMenuService, + @IConfigurationService configurationService: IConfigurationService, + @IContextKeyService contextKeyService: IContextKeyService, + @IViewDescriptorService viewDescriptorService: IViewDescriptorService, + @IInstantiationService instantiationService: IInstantiationService, + @IOpenerService openerService: IOpenerService, + @IThemeService themeService: IThemeService, + @ITelemetryService telemetryService: ITelemetryService, + @IExtensionService private readonly extensionService: IExtensionService, + @IProgressService private readonly progressService: IProgressService, + @IWebviewService private readonly webviewService: IWebviewService, + @IWebviewViewService private readonly webviewViewService: IWebviewViewService, + ) { + super({ ...options, titleMenuId: MenuId.ViewTitle }, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService); + + this._register(this.onDidChangeBodyVisibility(() => this.updateTreeVisibility())); + this.updateTreeVisibility(); + } + + private readonly _onDidChangeVisibility = this._register(new Emitter()); + readonly onDidChangeVisibility = this._onDidChangeVisibility.event; + + private readonly _onDispose = this._register(new Emitter()); + readonly onDispose = this._onDispose.event; + + dispose() { + this._onDispose.fire(); + + super.dispose(); + } + + focus(): void { + super.focus(); + this._webview?.focus(); + } + + renderBody(container: HTMLElement): void { + super.renderBody(container); + + this._container = container; + + if (!this._resizeObserver) { + this._resizeObserver = new ResizeObserver(() => { + setImmediate(() => { + if (this._container) { + this._webview?.layoutWebviewOverElement(this._container); + } + }); + }); + + this._register(toDisposable(() => { + this._resizeObserver.disconnect(); + })); + this._resizeObserver.observe(container); + } + } + + protected layoutBody(height: number, width: number): void { + super.layoutBody(height, width); + + if (!this._webview) { + return; + } + + if (this._container) { + this._webview.layoutWebviewOverElement(this._container, { width, height }); + } + } + + private updateTreeVisibility() { + if (this.isBodyVisible()) { + this.activate(); + this._webview?.claim(this); + } else { + this._webview?.release(this); + } + } + + private activate() { + if (!this._activated) { + this._activated = true; + + const webview = this.webviewService.createWebviewOverlay(generateUuid(), {}, {}, undefined); + this._webview = webview; + + this._register(toDisposable(() => { + this._webview?.release(this); + })); + + const source = this._register(new CancellationTokenSource()); + + this.withProgress(async () => { + await this.extensionService.activateByEvent(`onView:${this.id}`); + + let self = this; + await this.webviewViewService.resolve(this.id, { + webview, + onDidChangeVisibility: this.onDidChangeBodyVisibility, + onDispose: this.onDispose, + get title() { return self.title; }, + set title(value: string) { self.updateTitle(value); } + }, source.token); + }); + } + } + + private async withProgress(task: () => Promise): Promise { + return this.progressService.withProgress({ location: this.id, delay: 500 }, task); + } +} + + diff --git a/src/vs/workbench/contrib/webviewView/browser/webviewViewService.ts b/src/vs/workbench/contrib/webviewView/browser/webviewViewService.ts new file mode 100644 index 00000000000..5de370ac42d --- /dev/null +++ b/src/vs/workbench/contrib/webviewView/browser/webviewViewService.ts @@ -0,0 +1,84 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Event } from 'vs/base/common/event'; +import { Disposable, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; +import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; +import { WebviewOverlay } from 'vs/workbench/contrib/webview/browser/webview'; + +export const IWebviewViewService = createDecorator('webviewViewService'); + +export interface WebviewView { + title?: string; + + readonly webview: WebviewOverlay; + + readonly onDidChangeVisibility: Event; + readonly onDispose: Event; +} + +export interface IWebviewViewResolver { + resolve(webviewView: WebviewView, cancellation: CancellationToken): Promise; +} + +export interface IWebviewViewService { + + readonly _serviceBrand: undefined; + + register(type: string, resolver: IWebviewViewResolver): IDisposable; + + resolve(viewType: string, webview: WebviewView, cancellation: CancellationToken): Promise; +} + +export class WebviewViewService extends Disposable implements IWebviewViewService { + + readonly _serviceBrand: undefined; + + private readonly _views = new Map(); + + private readonly _awaitingRevival = new Map void }>(); + + constructor() { + super(); + } + + register(viewType: string, resolver: IWebviewViewResolver): IDisposable { + if (this._views.has(viewType)) { + throw new Error(`View resolver already registered for ${viewType}`); + } + + this._views.set(viewType, resolver); + + const pending = this._awaitingRevival.get(viewType); + if (pending) { + resolver.resolve(pending.webview, CancellationToken.None).then(() => { + this._awaitingRevival.delete(viewType); + pending.resolve(); + }); + } + + return toDisposable(() => { + this._views.delete(viewType); + }); + } + + resolve(viewType: string, webview: WebviewView, cancellation: CancellationToken): Promise { + const resolver = this._views.get(viewType); + if (!resolver) { + if (this._awaitingRevival.has(viewType)) { + throw new Error('View already awaiting revival'); + } + + let resolve: () => void; + const p = new Promise(r => resolve = r); + this._awaitingRevival.set(viewType, { webview, resolve: resolve! }); + return p; + } + + return resolver.resolve(webview, cancellation); + } +} + diff --git a/src/vs/workbench/workbench.common.main.ts b/src/vs/workbench/workbench.common.main.ts index 6d6d66e1381..4a06e4a5ffc 100644 --- a/src/vs/workbench/workbench.common.main.ts +++ b/src/vs/workbench/workbench.common.main.ts @@ -199,6 +199,7 @@ import 'vs/workbench/contrib/url/browser/url.contribution'; // Webview import 'vs/workbench/contrib/webview/browser/webview.contribution'; +import 'vs/workbench/contrib/webviewView/browser/webviewView.contribution'; import 'vs/workbench/contrib/customEditor/browser/customEditor.contribution'; // Extensions Management