From 78dd2b492fa6af5a89deb3878ab30ba6e4ae9afe Mon Sep 17 00:00:00 2001 From: Mark Sujew Date: Sun, 6 Feb 2022 20:44:07 +0100 Subject: [PATCH] Implement vscode WebviewView API (#10705) --- .../editor/src/browser/editor-preferences.ts | 6 +- .../plugin-ext/src/common/plugin-api-rpc.ts | 23 ++ .../plugin-ext/src/common/plugin-protocol.ts | 7 + .../src/hosted/node/scanners/scanner-theia.ts | 3 +- .../src/main/browser/main-context.ts | 4 + .../main/browser/view/plugin-view-registry.ts | 78 ++++++- .../webview-views/webview-views-main.ts | 142 ++++++++++++ .../browser/webview-views/webview-views.ts | 38 ++++ .../plugin-ext/src/plugin/plugin-context.ts | 11 + .../plugin-ext/src/plugin/webview-views.ts | 213 ++++++++++++++++++ packages/plugin-ext/src/plugin/webviews.ts | 30 +++ packages/plugin/src/theia.d.ts | 145 ++++++++++++ 12 files changed, 692 insertions(+), 8 deletions(-) create mode 100644 packages/plugin-ext/src/main/browser/webview-views/webview-views-main.ts create mode 100644 packages/plugin-ext/src/main/browser/webview-views/webview-views.ts create mode 100644 packages/plugin-ext/src/plugin/webview-views.ts diff --git a/packages/editor/src/browser/editor-preferences.ts b/packages/editor/src/browser/editor-preferences.ts index b15b44c2eb966..d989243696cff 100644 --- a/packages/editor/src/browser/editor-preferences.ts +++ b/packages/editor/src/browser/editor-preferences.ts @@ -755,13 +755,15 @@ const codeEditorPreferenceProperties = { 'editor.peekWidgetDefaultFocus': { 'enumDescriptions': [ 'Focus the tree when opening peek', - 'Focus the editor when opening peek' + 'Focus the editor when opening peek', + 'Focus the webview when opening peek' ], 'description': 'Controls whether to focus the inline editor or the tree in the peek widget.', 'type': 'string', 'enum': [ 'tree', - 'editor' + 'editor', + 'webview' ], 'default': 'tree' }, diff --git a/packages/plugin-ext/src/common/plugin-api-rpc.ts b/packages/plugin-ext/src/common/plugin-api-rpc.ts index 851329392fec5..3b0ede3cb3865 100644 --- a/packages/plugin-ext/src/common/plugin-api-rpc.ts +++ b/packages/plugin-ext/src/common/plugin-api-rpc.ts @@ -1544,6 +1544,27 @@ export interface WebviewsMain { $unregisterSerializer(viewType: string): void; } +export interface WebviewViewsExt { + $resolveWebviewView(handle: string, + viewType: string, + title: string | undefined, + state: any, + cancellation: CancellationToken): Promise; + $onDidChangeWebviewViewVisibility(handle: string, visible: boolean): void; + $disposeWebviewView(handle: string): void; +} + +export interface WebviewViewsMain extends Disposable { + $registerWebviewViewProvider(viewType: string, + options: { retainContextWhenHidden?: boolean, serializeBuffersForPostMessage: boolean }): void; + $unregisterWebviewViewProvider(viewType: string): void; + + $setWebviewViewTitle(handle: string, value: string | undefined): void; + $setWebviewViewDescription(handle: string, value: string | undefined): void; + + $show(handle: string, preserveFocus: boolean): void; +} + export interface CustomEditorsExt { $resolveWebviewEditor( resource: UriComponents, @@ -1718,6 +1739,7 @@ export const PLUGIN_RPC_CONTEXT = { CONNECTION_MAIN: createProxyIdentifier('ConnectionMain'), WEBVIEWS_MAIN: createProxyIdentifier('WebviewsMain'), CUSTOM_EDITORS_MAIN: createProxyIdentifier('CustomEditorsMain'), + WEBVIEW_VIEWS_MAIN: createProxyIdentifier('WebviewViewsMain'), STORAGE_MAIN: createProxyIdentifier('StorageMain'), TASKS_MAIN: createProxyIdentifier('TasksMain'), DEBUG_MAIN: createProxyIdentifier('DebugMain'), @@ -1752,6 +1774,7 @@ export const MAIN_RPC_CONTEXT = { CONNECTION_EXT: createProxyIdentifier('ConnectionExt'), WEBVIEWS_EXT: createProxyIdentifier('WebviewsExt'), CUSTOM_EDITORS_EXT: createProxyIdentifier('CustomEditorsExt'), + WEBVIEW_VIEWS_EXT: createProxyIdentifier('WebviewViewsExt'), STORAGE_EXT: createProxyIdentifier('StorageExt'), TASKS_EXT: createProxyIdentifier('TasksExt'), DEBUG_EXT: createProxyIdentifier('DebugExt'), diff --git a/packages/plugin-ext/src/common/plugin-protocol.ts b/packages/plugin-ext/src/common/plugin-protocol.ts index d603bf89d0979..62eb39e9e6477 100644 --- a/packages/plugin-ext/src/common/plugin-protocol.ts +++ b/packages/plugin-ext/src/common/plugin-protocol.ts @@ -114,10 +114,16 @@ export interface PluginPackageViewContainer { icon: string; } +export enum PluginViewType { + Tree = 'tree', + Webview = 'webview' +} + export interface PluginPackageView { id: string; name: string; when?: string; + type?: string; } export interface PluginPackageViewWelcome { @@ -659,6 +665,7 @@ export interface View { id: string; name: string; when?: string; + type?: string; } /** diff --git a/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts b/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts index 8c7187d42dbfa..f0eb7f79efd8d 100644 --- a/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts +++ b/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts @@ -530,7 +530,8 @@ export class TheiaPluginScanner implements PluginScanner { const result: View = { id: rawView.id, name: rawView.name, - when: rawView.when + when: rawView.when, + type: rawView.type }; return result; diff --git a/packages/plugin-ext/src/main/browser/main-context.ts b/packages/plugin-ext/src/main/browser/main-context.ts index 36ab3504ec86e..21a7705d32fcb 100644 --- a/packages/plugin-ext/src/main/browser/main-context.ts +++ b/packages/plugin-ext/src/main/browser/main-context.ts @@ -56,6 +56,7 @@ import { ThemingMainImpl } from './theming-main'; import { CommentsMainImp } from './comments/comments-main'; import { CustomEditorsMainImpl } from './custom-editors/custom-editors-main'; import { NotificationExtImpl } from '../../plugin/notification'; +import { WebviewViewsMainImpl } from './webview-views/webview-views-main'; export function setUpPluginApi(rpc: RPCProtocol, container: interfaces.Container): void { const authenticationMain = new AuthenticationMainImpl(rpc, container); @@ -130,6 +131,9 @@ export function setUpPluginApi(rpc: RPCProtocol, container: interfaces.Container const customEditorsMain = new CustomEditorsMainImpl(rpc, container, webviewsMain); rpc.set(PLUGIN_RPC_CONTEXT.CUSTOM_EDITORS_MAIN, customEditorsMain); + const webviewViewsMain = new WebviewViewsMainImpl(rpc, container, webviewsMain); + rpc.set(PLUGIN_RPC_CONTEXT.WEBVIEW_VIEWS_MAIN, webviewViewsMain); + const storageMain = new StorageMainImpl(container); rpc.set(PLUGIN_RPC_CONTEXT.STORAGE_MAIN, storageMain); diff --git a/packages/plugin-ext/src/main/browser/view/plugin-view-registry.ts b/packages/plugin-ext/src/main/browser/view/plugin-view-registry.ts index 89c2891e3146d..1d1690f83b332 100644 --- a/packages/plugin-ext/src/main/browser/view/plugin-view-registry.ts +++ b/packages/plugin-ext/src/main/browser/view/plugin-view-registry.ts @@ -20,7 +20,7 @@ import { ViewContainerIdentifier, ViewContainerTitleOptions, Widget, FrontendApplicationContribution, StatefulWidget, CommonMenus, BaseWidget, TreeViewWelcomeWidget } from '@theia/core/lib/browser'; -import { ViewContainer, View, ViewWelcome } from '../../../common'; +import { ViewContainer, View, ViewWelcome, PluginViewType } from '../../../common'; import { PluginSharedStyle } from '../plugin-shared-style'; import { DebugWidget } from '@theia/debug/lib/browser/view/debug-widget'; import { PluginViewWidget, PluginViewWidgetIdentifier } from './plugin-view-widget'; @@ -32,7 +32,7 @@ import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposa import { CommandRegistry } from '@theia/core/lib/common/command'; import { MenuModelRegistry } from '@theia/core/lib/common/menu'; import { QuickViewService } from '@theia/core/lib/browser/quick-view-service'; -import { Emitter } from '@theia/core/lib/common/event'; +import { Emitter, Event } from '@theia/core/lib/common/event'; import { ContextKeyService } from '@theia/core/lib/browser/context-key-service'; import { SearchInWorkspaceWidget } from '@theia/search-in-workspace/lib/browser/search-in-workspace-widget'; import { ViewContextKeyService } from './view-context-key-service'; @@ -42,6 +42,11 @@ import { DebugConsoleContribution } from '@theia/debug/lib/browser/console/debug import { TERMINAL_WIDGET_FACTORY_ID } from '@theia/terminal/lib/browser/terminal-widget-impl'; import { TreeViewWidget } from './tree-view-widget'; +import { WebviewView, WebviewViewResolver } from '../webview-views/webview-views'; +import { WebviewWidget, WebviewWidgetIdentifier } from '../webview/webview'; +import { CancellationToken } from '@theia/core/lib/common/cancellation'; +import { v4 } from 'uuid'; + export const PLUGIN_VIEW_FACTORY_ID = 'plugin-view'; export const PLUGIN_VIEW_CONTAINER_FACTORY_ID = 'plugin-view-container'; export const PLUGIN_VIEW_DATA_FACTORY_ID = 'plugin-view-data'; @@ -96,6 +101,8 @@ export class PluginViewRegistry implements FrontendApplicationContribution { private readonly viewDataProviders = new Map(); private readonly viewDataState = new Map(); + private readonly webviewViewResolvers = new Map(); + @postConstruct() protected init(): void { // VS Code Viewlets @@ -314,6 +321,63 @@ export class PluginViewRegistry implements FrontendApplicationContribution { return toDispose; } + async registerWebviewView(viewId: string, resolver: WebviewViewResolver): Promise { + if (this.webviewViewResolvers.has(viewId)) { + throw new Error(`View resolver already registered for ${viewId}`); + } + this.webviewViewResolvers.set(viewId, resolver); + + const webviewView = await this.createNewWebviewView(); + const token = CancellationToken.None; + this.getView(viewId).then(async view => { + if (view) { + if (view.isVisible) { + await this.prepareView(view, webviewView.webview.identifier.id); + } else { + const toDisposeOnDidExpandView = new DisposableCollection(this.onDidExpandView(async id => { + if (id === viewId) { + dispose(); + await this.prepareView(view, webviewView.webview.identifier.id); + } + })); + const dispose = () => toDisposeOnDidExpandView.dispose(); + view.disposed.connect(dispose); + toDisposeOnDidExpandView.push(Disposable.create(() => view.disposed.disconnect(dispose))); + } + } + }); + + resolver.resolve(webviewView, token); + + return Disposable.create(() => { + this.webviewViewResolvers.delete(viewId); + }); + } + + async createNewWebviewView(): Promise { + const webview = await this.widgetManager.getOrCreateWidget( + WebviewWidget.FACTORY_ID, { id: v4() }); + webview.setContentOptions({ allowScripts: true }); + + let _description: string | undefined; + + return { + webview, + + get onDidChangeVisibility(): Event { return webview.onDidChangeVisibility; }, + get onDidDispose(): Event { return webview.onDidDispose; }, + + get title(): string | undefined { return webview.title.label; }, + set title(value: string | undefined) { webview.title.label = value || ''; }, + + get description(): string | undefined { return _description; }, + set description(value: string | undefined) { _description = value; }, + + dispose: webview.dispose, + show: webview.show + }; + } + registerViewWelcome(viewWelcome: ViewWelcome): Disposable { const toDispose = new DisposableCollection(); @@ -390,7 +454,7 @@ export class PluginViewRegistry implements FrontendApplicationContribution { return this.getView(viewId); } - protected async prepareView(widget: PluginViewWidget): Promise { + protected async prepareView(widget: PluginViewWidget, webviewId?: string): Promise { const data = this.views.get(widget.options.viewId); if (!data) { return; @@ -398,7 +462,7 @@ export class PluginViewRegistry implements FrontendApplicationContribution { const [, view] = data; widget.title.label = view.name; const currentDataWidget = widget.widgets[0]; - const viewDataWidget = await this.createViewDataWidget(view.id); + const viewDataWidget = await this.createViewDataWidget(view.id, webviewId); if (widget.isDisposed) { // eslint-disable-next-line no-unused-expressions viewDataWidget?.dispose(); @@ -633,8 +697,12 @@ export class PluginViewRegistry implements FrontendApplicationContribution { return toDispose; } - protected async createViewDataWidget(viewId: string): Promise { + protected async createViewDataWidget(viewId: string, webviewId?: string): Promise { const view = this.views.get(viewId); + if (view?.[1]?.type === PluginViewType.Webview) { + const webviewWidget = this.widgetManager.getWidget(WebviewWidget.FACTORY_ID, { id: webviewId }); + return webviewWidget; + } const provider = this.viewDataProviders.get(viewId); if (!view || !provider) { return undefined; diff --git a/packages/plugin-ext/src/main/browser/webview-views/webview-views-main.ts b/packages/plugin-ext/src/main/browser/webview-views/webview-views-main.ts new file mode 100644 index 0000000000000..0dd1679b2679c --- /dev/null +++ b/packages/plugin-ext/src/main/browser/webview-views/webview-views-main.ts @@ -0,0 +1,142 @@ +/******************************************************************************** + * Copyright (C) 2021 SAP SE or an SAP affiliate company and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +// some code copied and modified from https://github.com/microsoft/vscode/blob/e1f0f8f51390dea5df9096718fb6b647ed5a9534/src/vs/workbench/api/browser/mainThreadWebviewViews.ts + +import { inject, interfaces } from '@theia/core/shared/inversify'; +import { WebviewViewsMain, MAIN_RPC_CONTEXT, WebviewViewsExt } from '../../../common/plugin-api-rpc'; +import { RPCProtocol } from '../../../common/rpc-protocol'; +import { Disposable, DisposableCollection, ILogger } from '@theia/core'; +import { WebviewView } from './webview-views'; +import { CancellationToken } from '@theia/core/lib/common/cancellation'; +import { WebviewsMainImpl } from '../webviews-main'; +import { Widget, WidgetManager } from '@theia/core/lib/browser'; +import { PluginViewRegistry } from '../view/plugin-view-registry'; + +export class WebviewViewsMainImpl implements WebviewViewsMain, Disposable { + + protected readonly proxy: WebviewViewsExt; + protected readonly toDispose = new DisposableCollection( + Disposable.create(() => { /* mark as not disposed */ }) + ); + + protected readonly webviewViews = new Map(); + protected readonly webviewViewProviders = new Map(); + protected readonly widgetManager: WidgetManager; + protected readonly pluginViewRegistry: PluginViewRegistry; + + @inject(ILogger) + protected readonly logger: ILogger; + + constructor(rpc: RPCProtocol, + container: interfaces.Container, + readonly webviewsMain: WebviewsMainImpl + ) { + this.proxy = rpc.getProxy(MAIN_RPC_CONTEXT.WEBVIEW_VIEWS_EXT); + this.widgetManager = container.get(WidgetManager); + this.pluginViewRegistry = container.get(PluginViewRegistry); + } + + dispose(): void { + this.toDispose.dispose(); + } + + async $registerWebviewViewProvider(viewType: string, options: { retainContextWhenHidden?: boolean, serializeBuffersForPostMessage: boolean }): Promise { + + if (this.webviewViewProviders.has(viewType)) { + throw new Error(`View provider for ${viewType} already registered`); + } + + const registration = await this.pluginViewRegistry.registerWebviewView(viewType, { + resolve: async (webviewView: WebviewView, cancellation: CancellationToken) => { + const handle = webviewView.webview.identifier.id; + this.webviewViews.set(handle, webviewView); + this.webviewsMain.hookWebview(webviewView.webview); + + let state: string | undefined; + if (webviewView.webview.state) { + try { + state = JSON.parse(webviewView.webview.state); + console.log(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.onDidDispose(() => { + this.proxy.$disposeWebviewView(handle); + this.webviewViews.delete(handle); + }); + + try { + this.proxy.$resolveWebviewView(handle, viewType, webviewView.title, state, cancellation); + } catch (error) { + this.logger.error(`Error resolving webview view '${viewType}': ${error}`); + webviewView.webview.setHTML('failed to load plugin webview view'); + } + } + }); + + this.webviewViewProviders.set(viewType, registration); + } + + protected getWebview(handle: string): Widget | undefined { + return this.widgetManager.tryGetWidget(handle); + } + + $unregisterWebviewViewProvider(viewType: string): void { + const provider = this.webviewViewProviders.get(viewType); + if (!provider) { + throw new Error(`No view provider for ${viewType} registered`); + } + provider.dispose(); + this.webviewViewProviders.delete(viewType); + } + + $setWebviewViewTitle(handle: string, value: string | undefined): void { + const webviewView = this.getWebviewView(handle); + webviewView.title = value; + } + + $setWebviewViewDescription(handle: string, value: string | undefined): void { + const webviewView = this.getWebviewView(handle); + webviewView.description = value; + } + + $show(handle: string, preserveFocus: boolean): void { + const webviewView = this.getWebviewView(handle); + webviewView.show(preserveFocus); + } + + protected getWebviewView(handle: string): WebviewView { + const webviewView = this.webviewViews.get(handle); + if (!webviewView) { + throw new Error(`No webview view registered for handle '${handle}'`); + } + return webviewView; + } + +} diff --git a/packages/plugin-ext/src/main/browser/webview-views/webview-views.ts b/packages/plugin-ext/src/main/browser/webview-views/webview-views.ts new file mode 100644 index 0000000000000..4f88d2bd65288 --- /dev/null +++ b/packages/plugin-ext/src/main/browser/webview-views/webview-views.ts @@ -0,0 +1,38 @@ +/******************************************************************************** + * Copyright (C) 2021 SAP SE or an SAP affiliate company and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Microsoft Corporation. All rights reserved. +* Licensed under the MIT License. See License.txt in the project root for license information. +*--------------------------------------------------------------------------------------------*/ +// copied and modified from https://github.com/microsoft/vscode/blob/a4a4cf5ace4472bc4f5176396bb290cafa15c518/src/vs/workbench/contrib/webviewView/browser/webviewViewService.ts + +import { CancellationToken, Event } from '@theia/core/lib/common'; +import { WebviewWidget } from '../webview/webview'; + +export interface WebviewView { + title?: string; + description?: string; + readonly webview: WebviewWidget; + readonly onDidChangeVisibility: Event; + readonly onDidDispose: Event; + + dispose(): void; + show(preserveFocus: boolean): void; +} + +export interface WebviewViewResolver { + resolve(webviewView: WebviewView, cancellation: CancellationToken): Promise; +} diff --git a/packages/plugin-ext/src/plugin/plugin-context.ts b/packages/plugin-ext/src/plugin/plugin-context.ts index 10b664e32c4a3..6672531c2c189 100644 --- a/packages/plugin-ext/src/plugin/plugin-context.ts +++ b/packages/plugin-ext/src/plugin/plugin-context.ts @@ -169,6 +169,7 @@ import { TimelineExtImpl } from './timeline'; import { ThemingExtImpl } from './theming'; import { CommentsExtImpl } from './comments'; import { CustomEditorsExtImpl } from './custom-editors'; +import { WebviewViewsExtImpl } from './webview-views'; export function createAPIFactory( rpc: RPCProtocol, @@ -207,6 +208,7 @@ export function createAPIFactory( const themingExt = rpc.set(MAIN_RPC_CONTEXT.THEMING_EXT, new ThemingExtImpl(rpc)); const commentsExt = rpc.set(MAIN_RPC_CONTEXT.COMMENTS_EXT, new CommentsExtImpl(rpc, commandRegistry, documents)); const customEditorExt = rpc.set(MAIN_RPC_CONTEXT.CUSTOM_EDITORS_EXT, new CustomEditorsExtImpl(rpc, documents, webviewExt, workspaceExt)); + const webviewViewsExt = rpc.set(MAIN_RPC_CONTEXT.WEBVIEW_VIEWS_EXT, new WebviewViewsExtImpl(rpc, webviewExt)); rpc.set(MAIN_RPC_CONTEXT.DEBUG_EXT, debugExt); return function (plugin: InternalPlugin): typeof theia { @@ -406,6 +408,15 @@ export function createAPIFactory( options: { webviewOptions?: theia.WebviewPanelOptions, supportsMultipleEditorsPerDocument?: boolean } = {}): theia.Disposable { return customEditorExt.registerCustomEditorProvider(viewType, provider, options, plugin); }, + registerWebviewViewProvider(viewType: string, + provider: theia.WebviewViewProvider, + options?: { + webviewOptions?: { + retainContextWhenHidden?: boolean + } + }): theia.Disposable { + return webviewViewsExt.registerWebviewViewProvider(viewType, provider, plugin, options?.webviewOptions); + }, get state(): theia.WindowState { return windowStateExt.getWindowState(); }, diff --git a/packages/plugin-ext/src/plugin/webview-views.ts b/packages/plugin-ext/src/plugin/webview-views.ts new file mode 100644 index 0000000000000..94e87374a199e --- /dev/null +++ b/packages/plugin-ext/src/plugin/webview-views.ts @@ -0,0 +1,213 @@ +/******************************************************************************** + * Copyright (C) 2021 SAP SE or an SAP affiliate company and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Microsoft Corporation. All rights reserved. +* Licensed under the MIT License. See License.txt in the project root for license information. +*--------------------------------------------------------------------------------------------*/ +// some of the code is copied and modified from https://github.com/microsoft/vscode/blob/e1f0f8f51390dea5df9096718fb6b647ed5a9534/src/vs/workbench/api/common/extHostWebviewView.ts + +import { Disposable } from './types-impl'; +import { RPCProtocol } from '../common/rpc-protocol'; +import { PLUGIN_RPC_CONTEXT, WebviewViewsMain, WebviewViewsExt, Plugin } from '../common/plugin-api-rpc'; +import { CancellationToken } from '@theia/core/lib/common/cancellation'; +import { WebviewImpl, WebviewsExtImpl } from './webviews'; +import { WebviewViewProvider } from '@theia/plugin'; +import { Emitter, Event } from '@theia/core/lib/common/event'; +import * as theia from '@theia/plugin'; + +export class WebviewViewsExtImpl implements WebviewViewsExt { + + private readonly proxy: WebviewViewsMain; + + protected readonly viewProviders = new Map(); + protected readonly webviewViews = new Map(); + + constructor(rpc: RPCProtocol, + private readonly webviewsExt: WebviewsExtImpl) { + this.proxy = rpc.getProxy(PLUGIN_RPC_CONTEXT.WEBVIEW_VIEWS_MAIN); + } + + registerWebviewViewProvider( + viewType: string, + provider: WebviewViewProvider, + plugin: Plugin, + webviewOptions?: { + retainContextWhenHidden?: boolean + } + ): Disposable { + if (this.viewProviders.has(viewType)) { + throw new Error(`View provider for '${viewType}' already registered`); + } + + this.viewProviders.set(viewType, { provider: provider, plugin: plugin }); + + this.proxy.$registerWebviewViewProvider(viewType, { + retainContextWhenHidden: webviewOptions?.retainContextWhenHidden, + serializeBuffersForPostMessage: false, + }); + + return new Disposable(() => { + this.viewProviders.delete(viewType); + this.proxy.$unregisterWebviewViewProvider(viewType); + }); + } + + async $resolveWebviewView(handle: string, + viewType: string, + title: string | undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + state: any, + cancellation: CancellationToken + ): Promise { + const entry = this.viewProviders.get(viewType); + if (!entry) { + throw new Error(`No view provider found for '${viewType}'`); + } + + const { provider, plugin } = entry; + + const webviewNoPanel = this.webviewsExt.createNewWebview({}, plugin, handle); + const revivedView = new WebviewViewExtImpl(handle, this.proxy, viewType, title, webviewNoPanel, true); + this.webviewViews.set(handle, revivedView); + await provider.resolveWebviewView(revivedView, { state }, cancellation); + } + + async $onDidChangeWebviewViewVisibility( + handle: string, + visible: boolean + ): Promise { + const webviewView = this.getWebviewView(handle); + webviewView.setVisible(visible); + webviewView.onDidChangeVisibilityEmitter.fire(visible); + } + + async $disposeWebviewView(handle: string): Promise { + const webviewView = this.getWebviewView(handle); + this.webviewViews.delete(handle); + webviewView.dispose(); + + this.webviewsExt.deleteWebview(handle); + } + + protected getWebviewView(handle: string): WebviewViewExtImpl { + const entry = this.webviewViews.get(handle); + if (!entry) { + throw new Error('No webview found'); + } + + return entry; + } +} + +export class WebviewViewExtImpl implements theia.WebviewView { + + readonly onDidChangeVisibilityEmitter = new Emitter(); + readonly onDidChangeVisibility = this.onDidChangeVisibilityEmitter.event; + + readonly onDidDisposeEmitter = new Emitter(); + readonly onDidDispose = this.onDidDisposeEmitter.event; + + readonly handle: string; + readonly proxy: WebviewViewsMain; + + readonly _viewType: string; + readonly _webview: WebviewImpl; + + _isDisposed = false; + _isVisible: boolean; + _title: string | undefined; + _description: string | undefined; + + constructor( + handle: string, + proxy: WebviewViewsMain, + viewType: string, + title: string | undefined, + webview: WebviewImpl, + isVisible: boolean, + ) { + this._viewType = viewType; + this._title = title; + this.handle = handle; + this.proxy = proxy; + this._webview = webview; + this._isVisible = isVisible; + } + onDispose: Event; + + dispose(): void { + if (this._isDisposed) { + return; + } + + this._isDisposed = true; + this.onDidDisposeEmitter.fire(); + } + + 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 description(): string | undefined { + this.assertNotDisposed(); + return this._description; + } + + set description(value: string | undefined) { + this.assertNotDisposed(); + if (this._description !== value) { + this._description = value; + this.proxy.$setWebviewViewDescription(this.handle, value); + } + } + + get visible(): boolean { return this._isVisible; } + get webview(): WebviewImpl { return this._webview; } + get viewType(): string { return this._viewType; } + + setVisible(visible: boolean): void { + if (visible === this._isVisible || this._isDisposed) { + return; + } + + this._isVisible = visible; + this.onDidChangeVisibilityEmitter.fire(this._isVisible); + } + + show(preserveFocus?: boolean): void { + this.assertNotDisposed(); + this.proxy.$show(this.handle, !!preserveFocus); + } + + protected assertNotDisposed(): void { + if (this._isDisposed) { + throw new Error('Webview is disposed'); + } + } +} + diff --git a/packages/plugin-ext/src/plugin/webviews.ts b/packages/plugin-ext/src/plugin/webviews.ts index a2e2c8fcec2ac..086cb8e0ddf39 100644 --- a/packages/plugin-ext/src/plugin/webviews.ts +++ b/packages/plugin-ext/src/plugin/webviews.ts @@ -28,12 +28,16 @@ import { PluginIconPath } from './plugin-icon-path'; export class WebviewsExtImpl implements WebviewsExt { private readonly proxy: WebviewsMain; private readonly webviewPanels = new Map(); + private readonly webviews = new Map(); private readonly serializers = new Map(); private initData: WebviewInitData | undefined; + readonly onDidDisposeEmitter = new Emitter(); + readonly onDidDispose: Event = this.onDidDisposeEmitter.event; + constructor( rpc: RPCProtocol, private readonly workspace: WorkspaceExtImpl, @@ -50,6 +54,11 @@ export class WebviewsExtImpl implements WebviewsExt { const panel = this.getWebviewPanel(handle); if (panel) { panel.webview.onMessageEmitter.fire(message); + } else { + const webview = this.getWebview(handle); + if (webview) { + webview.onMessageEmitter.fire(message); + } } } $onDidChangeWebviewPanelViewState(handle: string, newState: WebviewPanelViewState): void { @@ -129,6 +138,19 @@ export class WebviewsExtImpl implements WebviewsExt { return panel; } + createNewWebview( + options: theia.WebviewPanelOptions & theia.WebviewOptions, + plugin: Plugin, + viewId: string + ): WebviewImpl { + if (!this.initData) { + throw new Error('Webviews are not initialized'); + } + const webview = new WebviewImpl(viewId, this.proxy, options, this.initData, this.workspace, plugin); + this.webviews.set(viewId, webview); + return webview; + } + registerWebviewPanelSerializer( viewType: string, serializer: theia.WebviewPanelSerializer, @@ -153,6 +175,14 @@ export class WebviewsExtImpl implements WebviewsExt { } return undefined; } + + public deleteWebview(handle: string): void { + this.webviews.delete(handle); + } + + public getWebview(handle: string): WebviewImpl | undefined { + return this.webviews.get(handle); + } } export class WebviewImpl implements theia.Webview { diff --git a/packages/plugin/src/theia.d.ts b/packages/plugin/src/theia.d.ts index 7b7ca29e91bf7..b320b5112cec8 100644 --- a/packages/plugin/src/theia.d.ts +++ b/packages/plugin/src/theia.d.ts @@ -3931,6 +3931,87 @@ declare module '@theia/plugin' { } + 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; + + /** + * Human-readable string which is rendered less prominently in the title. + */ + description?: string; + + /** + * Event fired when the view is disposed. + * + * Views are disposed when they are explicitly hidden by a user (this happens when a user + * right clicks in a view and unchecks the webview view). + * + * 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. + * + * Actions that trigger a visibility change: + * + * - The view is collapsed or expanded. + * - The user switches to a different view group in the sidebar or panel. + * + * Note that hiding a view using the context menu instead disposes of the view and fires `onDidDispose`. + */ + readonly onDidChangeVisibility: Event; + + /** + * Reveal the view in the UI. + * + * If the view is collapsed, this will expand it. + * + * @param preserveFocus When `true` the view will not take focus. + */ + show(preserveFocus?: boolean): void; + } + /** + * 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 view to restore. The provider should take ownership of this view. 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; + } + /** * Common namespace for dealing with window and editor, showing messages and user input. */ @@ -4264,6 +4345,70 @@ declare module '@theia/plugin' { * @param viewType Type of the webview panel that can be serialized. * @param serializer Webview serializer. */ + + /** + * Additional information the webview view being resolved. + * + * @param T Type of the webview's state. + */ + interface WebviewViewResolveContext { + /** + * Persisted state from the webview content. + * + * To save resources, VS Code normally deallocates webview documents (the iframe content) that are not visible. + * For example, when the user collapse a view or switches to another top level activity in the sidebar, the + * `WebviewView` itself is kept alive but the webview's underlying document is deallocated. It is recreated when + * the view becomes visible again. + * + * 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; + } + + export function registerWebviewViewProvider(viewId: string, provider: WebviewViewProvider, options?: { + /** + * Content settings for the webview created for this view. + */ + readonly webviewOptions?: { + /** + * Controls if the webview element itself (iframe) is kept around even when the view + * is no longer visible. + * + * Normally the webview's html context is created when the view becomes visible + * and destroyed when it is hidden. Extensions that have complex state + * or UI can set the `retainContextWhenHidden` to make the editor 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 view 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 view's context cannot be quickly saved and restored. + */ + readonly retainContextWhenHidden?: boolean; + }; + }): Disposable; + export function registerWebviewPanelSerializer(viewType: string, serializer: WebviewPanelSerializer): Disposable; /**