// ***************************************************************************** // Copyright (C) 2018 Red Hat, Inc. // // 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-only WITH Classpath-exception-2.0 // ***************************************************************************** import { generateUuid, hashValue } from '@theia/core/lib/common/uuid'; import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; import { Plugin, WebviewsExt, WebviewPanelViewState, WebviewsMain, PLUGIN_RPC_CONTEXT, WebviewInitData, /* WebviewsMain, PLUGIN_RPC_CONTEXT */ } from '../common/plugin-api-rpc'; import * as theia from '@theia/plugin'; import { RPCProtocol } from '../common/rpc-protocol'; import { Emitter, Event } from '@theia/core/lib/common/event'; import { fromViewColumn, toViewColumn, toWebviewPanelShowOptions } from './type-converters'; import { Disposable, WebviewPanelTargetArea, URI, ThemeIcon } from './types-impl'; import { WorkspaceExtImpl } from './workspace'; import { PluginIconPath } from './plugin-icon-path'; import { PluginModel, PluginPackage } from '../common'; @injectable() export class WebviewsExtImpl implements WebviewsExt { @inject(RPCProtocol) protected readonly rpc: RPCProtocol; @inject(WorkspaceExtImpl) protected readonly workspace: WorkspaceExtImpl; private 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; @postConstruct() initialize(): void { this.proxy = this.rpc.getProxy(PLUGIN_RPC_CONTEXT.WEBVIEWS_MAIN); } init(initData: WebviewInitData): void { this.initData = initData; } // eslint-disable-next-line @typescript-eslint/no-explicit-any $onMessage(handle: string, message: any): void { 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 { const panel = this.getWebviewPanel(handle); if (panel) { const viewColumn = toViewColumn(newState.position); if (panel.active !== newState.active || panel.visible !== newState.visible || panel.viewColumn !== viewColumn) { panel.setActive(newState.active); panel.setVisible(newState.visible); panel.setViewColumn(viewColumn!); panel.onDidChangeViewStateEmitter.fire({ webviewPanel: panel }); } } } $onDidDisposeWebviewPanel(handle: string): PromiseLike { const panel = this.getWebviewPanel(handle); if (panel) { panel.dispose(); this.webviewPanels.delete(handle); } return Promise.resolve(); } $deserializeWebviewPanel(viewId: string, viewType: string, title: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any state: any, viewState: WebviewPanelViewState, options: theia.WebviewOptions & theia.WebviewPanelOptions): PromiseLike { if (!this.initData) { return Promise.reject(new Error('Webviews are not initialized')); } const entry = this.serializers.get(viewType); if (!entry) { return Promise.reject(new Error(`No serializer found for '${viewType}'`)); } const { serializer, plugin } = entry; const webview = new WebviewImpl(viewId, this.proxy, options, this.initData, this.workspace, plugin, hashValue(viewType)); const revivedPanel = new WebviewPanelImpl(viewId, this.proxy, viewType, title, toViewColumn(viewState.position)!, options, webview); revivedPanel.setActive(viewState.active); revivedPanel.setVisible(viewState.visible); this.webviewPanels.set(viewId, revivedPanel); return serializer.deserializeWebviewPanel(revivedPanel, state); } createWebview( viewType: string, title: string, showOptions: theia.ViewColumn | theia.WebviewPanelShowOptions, options: theia.WebviewPanelOptions & theia.WebviewOptions, plugin: Plugin ): theia.WebviewPanel { const viewId = generateUuid(); const webviewShowOptions = toWebviewPanelShowOptions(showOptions); const webviewOptions = WebviewImpl.toWebviewOptions(options, this.workspace, plugin); this.proxy.$createWebviewPanel(viewId, viewType, title, webviewShowOptions, webviewOptions); const panel = this.createWebviewPanel(viewType, title, showOptions, options, plugin, viewId); return panel; } /** * Creates a new webview panel. * * @param viewType Identifies the type of the webview panel. * @param title Title of the panel. * @param showOptions Where webview panel will be reside. * @param options Settings for the new panel. * @param plugin The plugin contributing the webview. * @param viewId The identifier of the webview instance. * @param originBasedOnType true if a stable origin based on the viewType shall be used, false if the viewId should be used. * @returns The new webview panel. */ createWebviewPanel( viewType: string, title: string, showOptions: theia.ViewColumn | theia.WebviewPanelShowOptions, options: theia.WebviewPanelOptions & theia.WebviewOptions, plugin: Plugin, viewId: string, originBasedOnType = true ): WebviewPanelImpl { if (!this.initData) { throw new Error('Webviews are not initialized'); } const webviewShowOptions = toWebviewPanelShowOptions(showOptions); const origin = originBasedOnType ? hashValue(viewType) : undefined; const webview = new WebviewImpl(viewId, this.proxy, options, this.initData, this.workspace, plugin, origin); const panel = new WebviewPanelImpl(viewId, this.proxy, viewType, title, webviewShowOptions, options, webview); this.webviewPanels.set(viewId, panel); return panel; } createNewWebview( options: theia.WebviewPanelOptions & theia.WebviewOptions, plugin: Plugin, viewId: string, origin?: 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, origin); this.webviews.set(viewId, webview); return webview; } registerWebviewPanelSerializer( viewType: string, serializer: theia.WebviewPanelSerializer, plugin: Plugin ): theia.Disposable { if (this.serializers.has(viewType)) { throw new Error(`Serializer for '${viewType}' already registered`); } this.serializers.set(viewType, { serializer, plugin }); this.proxy.$registerSerializer(viewType); return new Disposable(() => { this.serializers.delete(viewType); this.proxy.$unregisterSerializer(viewType); }); } getWebviewPanel(viewId: string): WebviewPanelImpl | undefined { if (this.webviewPanels.has(viewId)) { return this.webviewPanels.get(viewId); } return undefined; } toGeneralWebviewResource(extension: PluginModel, resource: theia.Uri): theia.Uri { const extensionUri = URI.parse(extension.packageUri); const relativeResourcePath = resource.path.replace(extensionUri.path, ''); const basePath = PluginPackage.toPluginUrl(extension, '') + relativeResourcePath; return URI.parse(this.initData!.webviewResourceRoot.replace('{{uuid}}', 'webviewUUID')).with({ path: basePath }); } public deleteWebview(handle: string): void { this.webviews.delete(handle); } public getWebview(handle: string): WebviewImpl | undefined { return this.webviews.get(handle); } public getResourceRoot(): string | undefined { return this.initData?.webviewResourceRoot; } } export class WebviewImpl implements theia.Webview { private isDisposed = false; private _html: string; private _options: theia.WebviewOptions; // eslint-disable-next-line @typescript-eslint/no-explicit-any public readonly onMessageEmitter = new Emitter(); // eslint-disable-next-line @typescript-eslint/no-explicit-any public readonly onDidReceiveMessage: Event = this.onMessageEmitter.event; constructor( private readonly viewId: string, private readonly proxy: WebviewsMain, options: theia.WebviewOptions, private readonly initData: WebviewInitData, private readonly workspace: WorkspaceExtImpl, readonly plugin: Plugin, private readonly origin?: string ) { this._options = options; } dispose(): void { if (this.isDisposed) { return; } this.isDisposed = true; this.onMessageEmitter.dispose(); } asWebviewUri(resource: theia.Uri): theia.Uri { const uri = this.initData.webviewResourceRoot .replace('{{scheme}}', resource.scheme) .replace('{{authority}}', resource.authority) .replace('{{path}}', resource.path.replace(/^\//, '')) .replace('{{uuid}}', this.origin ?? this.viewId); return URI.parse(uri).with({ query: resource.query }); } get cspSource(): string { return this.initData.webviewCspSource.replace('{{uuid}}', this.origin ?? this.viewId); } get html(): string { this.checkIsDisposed(); return this._html; } set html(value: string) { this.checkIsDisposed(); if (this._html !== value) { this._html = value; this.proxy.$setHtml(this.viewId, this._html); } } get options(): theia.WebviewOptions { this.checkIsDisposed(); return this._options; } set options(newOptions: theia.WebviewOptions) { this.checkIsDisposed(); this.proxy.$setOptions(this.viewId, WebviewImpl.toWebviewOptions(newOptions, this.workspace, this.plugin)); this._options = newOptions; } // eslint-disable-next-line @typescript-eslint/no-explicit-any postMessage(message: any): PromiseLike { this.checkIsDisposed(); return this.proxy.$postMessage(this.viewId, message); } private checkIsDisposed(): void { if (this.isDisposed) { throw new Error('This Webview is disposed!'); } } static toWebviewOptions(options: theia.WebviewOptions, workspace: WorkspaceExtImpl, plugin: Plugin): theia.WebviewOptions { return { ...options, localResourceRoots: options.localResourceRoots || [ ...(workspace.workspaceFolders || []).map(x => x.uri), URI.file(plugin.pluginFolder) ] }; } } export class WebviewPanelImpl implements theia.WebviewPanel { private isDisposed = false; private _active = true; private _visible = true; private _showOptions: theia.WebviewPanelShowOptions; private _iconPath: theia.IconPath | undefined; readonly onDisposeEmitter = new Emitter(); public readonly onDidDispose: Event = this.onDisposeEmitter.event; readonly onDidChangeViewStateEmitter = new Emitter(); public readonly onDidChangeViewState: Event = this.onDidChangeViewStateEmitter.event; constructor(private readonly viewId: string, private readonly proxy: WebviewsMain, private readonly _viewType: string, private _title: string, showOptions: theia.ViewColumn | theia.WebviewPanelShowOptions, private readonly _options: theia.WebviewPanelOptions, private readonly _webview: WebviewImpl ) { this._showOptions = typeof showOptions === 'object' ? showOptions : { viewColumn: showOptions as theia.ViewColumn }; } dispose(): void { if (this.isDisposed) { return; } this.isDisposed = true; this.onDisposeEmitter.fire(undefined); this.proxy.$disposeWebview(this.viewId); this._webview.dispose(); this.onDisposeEmitter.dispose(); this.onDidChangeViewStateEmitter.dispose(); } get viewType(): string { this.checkIsDisposed(); return this._viewType; } get title(): string { this.checkIsDisposed(); return this._title; } set title(newTitle: string) { this.checkIsDisposed(); if (this._title !== newTitle) { this._title = newTitle; this.proxy.$setTitle(this.viewId, newTitle); } } get iconPath(): theia.IconPath | undefined { return this._iconPath; } set iconPath(iconPath: theia.IconPath | undefined) { this.checkIsDisposed(); if (this._iconPath !== iconPath) { this._iconPath = iconPath; if (ThemeIcon.is(iconPath)) { this.proxy.$setIconPath(this.viewId, ThemeIcon.get(iconPath)); } else { this.proxy.$setIconPath(this.viewId, PluginIconPath.toUrl(iconPath, this._webview.plugin)); } } } get webview(): WebviewImpl { this.checkIsDisposed(); return this._webview; } get options(): theia.WebviewPanelOptions { this.checkIsDisposed(); return this._options; } get viewColumn(): theia.ViewColumn | undefined { this.checkIsDisposed(); return this._showOptions.viewColumn; } setViewColumn(value: theia.ViewColumn | undefined): void { this.checkIsDisposed(); this._showOptions.viewColumn = value; } get showOptions(): theia.WebviewPanelShowOptions { this.checkIsDisposed(); return this._showOptions; } setShowOptions(value: theia.WebviewPanelShowOptions): void { this.checkIsDisposed(); this._showOptions = value; } get active(): boolean { this.checkIsDisposed(); return this._active; } setActive(value: boolean): void { this.checkIsDisposed(); this._active = value; } get visible(): boolean { this.checkIsDisposed(); return this._visible; } setVisible(value: boolean): void { this.checkIsDisposed(); this._visible = value; } reveal(arg0?: theia.ViewColumn | WebviewPanelTargetArea, arg1?: theia.ViewColumn | boolean, arg2?: boolean): void { let area: WebviewPanelTargetArea | undefined = undefined; let viewColumn: theia.ViewColumn | undefined = undefined; let preserveFocus: boolean | undefined = undefined; if (typeof arg0 === 'number') { viewColumn = arg0; } else { area = arg0; } if (typeof arg1 === 'number') { viewColumn = arg1; } else { preserveFocus = arg1; } if (typeof arg2 === 'boolean') { preserveFocus = arg2; } this.checkIsDisposed(); this.proxy.$reveal(this.viewId, { area, viewColumn: viewColumn ? fromViewColumn(viewColumn) : undefined, preserveFocus }); } // eslint-disable-next-line @typescript-eslint/no-explicit-any postMessage(message: any): PromiseLike { this.checkIsDisposed(); return this.proxy.$postMessage(this.viewId, message); } private checkIsDisposed(): void { if (this.isDisposed) { throw new Error('This WebviewPanel is disposed!'); } } }