Skip to content

Commit

Permalink
[webview] fix eclipse-theia#5647: restore webviews
Browse files Browse the repository at this point in the history
Signed-off-by: Anton Kosyakov <anton.kosyakov@typefox.io>
  • Loading branch information
akosyakov committed Nov 22, 2019
1 parent 52201df commit 17f56a2
Show file tree
Hide file tree
Showing 4 changed files with 136 additions and 62 deletions.
3 changes: 3 additions & 0 deletions packages/core/src/browser/shell/shell-layout-restorer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,9 @@ export class ShellLayoutRestorer implements CommandContribution {
this.logger.warn(`Couldn't restore widget state for ${widget.id}. Error: ${e} `);
}
}
if (widget.isDisposed) {
return undefined;
}
return widget;
} catch (e) {
if (ApplicationShellLayoutMigrationError.is(e)) {
Expand Down
92 changes: 92 additions & 0 deletions packages/plugin-ext/src/hosted/browser/hosted-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ import { FrontendApplicationStateService } from '@theia/core/lib/browser/fronten
import { PluginViewRegistry } from '../../main/browser/view/plugin-view-registry';
import { TaskProviderRegistry, TaskResolverRegistry } from '@theia/task/lib/browser/task-contribution';
import { WebviewEnvironment } from '../../main/browser/webview/webview-environment';
import { WebviewWidget } from '../../main/browser/webview/webview';
import { WidgetManager } from '@theia/core/lib/browser/widget-manager';

export type PluginHost = 'frontend' | string;
export type DebugActivationEvent = 'onDebugResolve' | 'onDebugInitialConfigurations' | 'onDebugAdapterProtocolTracker';
Expand Down Expand Up @@ -131,6 +133,9 @@ export class HostedPluginSupport {
@inject(WebviewEnvironment)
protected readonly webviewEnvironment: WebviewEnvironment;

@inject(WidgetManager)
protected readonly widgets: WidgetManager;

private theiaReadyPromise: Promise<any>;

protected readonly managers = new Map<string, PluginManagerExt>();
Expand Down Expand Up @@ -158,6 +163,26 @@ export class HostedPluginSupport {
this.viewRegistry.onDidExpandView(id => this.activateByView(id));
this.taskProviderRegistry.onWillProvideTaskProvider(event => this.ensureTaskActivation(event));
this.taskResolverRegistry.onWillProvideTaskResolver(event => this.ensureTaskActivation(event));
this.widgets.onDidCreateWidget(({ factoryId, widget }) => {
if (factoryId === WebviewWidget.FACTORY_ID && widget instanceof WebviewWidget) {
const storeState = widget.storeState.bind(widget);
const restoreState = widget.restoreState.bind(widget);
widget.storeState = () => {
if (this.webviewRevivers.has(widget.viewType)) {
return storeState();
}
return {};
};
widget.restoreState = oldState => {
if (oldState.viewType) {
restoreState(oldState);
this.preserveWebview(widget);
} else {
widget.dispose();
}
};
}
});
}

get plugins(): PluginMetadata[] {
Expand Down Expand Up @@ -185,6 +210,7 @@ export class HostedPluginSupport {

protected async doLoad(): Promise<void> {
const toDisconnect = new DisposableCollection(Disposable.create(() => { /* mark as connected */ }));
toDisconnect.push(Disposable.create(() => this.preserveWebviews()));
this.server.onDidCloseConnection(() => toDisconnect.dispose());

// process empty plugins as well in order to properly remove stale plugin widgets
Expand All @@ -211,6 +237,7 @@ export class HostedPluginSupport {
return;
}
await this.startPlugins(contributionsByHost, toDisconnect);
this.restoreWebviews();
}

/**
Expand Down Expand Up @@ -564,6 +591,71 @@ export class HostedPluginSupport {
console.log(`[${this.clientId}] ${prefix} of ${pluginCount} took: ${measurement()} ms`);
}

protected readonly webviewsToRestore = new Set<WebviewWidget>();
protected readonly webviewRevivers = new Map<string, (webview: WebviewWidget) => Promise<void>>();

registerWebviewReviver(viewType: string, reviver: (webview: WebviewWidget) => Promise<void>): void {
if (this.webviewRevivers.has(viewType)) {
throw new Error(`Reviver for ${viewType} already registered`);
}
this.webviewRevivers.set(viewType, reviver);
}

unregisterWebviewReviver(viewType: string): void {
this.webviewRevivers.delete(viewType);
}

protected preserveWebviews(): void {
for (const webview of this.widgets.getWidgets(WebviewWidget.FACTORY_ID)) {
this.preserveWebview(webview as WebviewWidget);
}
}

protected preserveWebview(webview: WebviewWidget): void {
if (!this.webviewsToRestore.has(webview)) {
this.webviewsToRestore.add(webview);
webview.disposed.connect(() => this.webviewsToRestore.delete(webview));
}
}

protected restoreWebviews(): void {
for (const webview of this.webviewsToRestore) {
this.restoreWebview(webview);
}
this.webviewsToRestore.clear();
}

protected async restoreWebview(webview: WebviewWidget): Promise<void> {
await this.activateByEvent(`onWebviewPanel:${webview.viewType}`);
const restore = this.webviewRevivers.get(webview.viewType);
if (!restore) {
webview.setHTML(this.getDeserializationFailedContents(`
<p>The extension providing '${webview.viewType}' view is not capable of restoring it.</p>
<p>Want to help fix this? Please inform the extension developer to register a <a href="https://code.visualstudio.com/api/extension-guides/webview#serialization">reviver</a>.</p>
`));
return;
}
try {
await restore(webview);
} catch (e) {
webview.setHTML(this.getDeserializationFailedContents(`
An error occurred while restoring '${webview.viewType}' view. Please check logs.
`));
console.error('Failed to restore the webview', e);
}
}

protected getDeserializationFailedContents(message: string): string {
return `<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-type" content="text/html;charset=UTF-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none';">
</head>
<body>${message}</body>
</html>`;
}

}

export class PluginContributions extends DisposableCollection {
Expand Down
13 changes: 9 additions & 4 deletions packages/plugin-ext/src/main/browser/webview/webview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

import { injectable, inject, postConstruct } from 'inversify';
import { ArrayExt } from '@phosphor/algorithm/lib/array';
import { WebviewPanelOptions, WebviewPortMapping, Uri } from '@theia/plugin';
import { WebviewPanelOptions, WebviewPortMapping } from '@theia/plugin';
import { BaseWidget, Message } from '@theia/core/lib/browser/widgets/widget';
import { Disposable } from '@theia/core/lib/common/disposable';
// TODO: get rid of dependencies to the mini browser
Expand All @@ -32,13 +32,15 @@ import { FileSystem } from '@theia/filesystem/lib/common/filesystem';
// tslint:disable:no-any

export const enum WebviewMessageChannels {
doUpdateState = 'do-update-state',
doReload = 'do-reload',
loadResource = 'load-resource',
webviewReady = 'webview-ready'
}

export interface WebviewContentOptions {
readonly allowScripts?: boolean;
readonly localResourceRoots?: ReadonlyArray<Uri>;
readonly localResourceRoots?: ReadonlyArray<string>;
readonly portMapping?: ReadonlyArray<WebviewPortMapping>;
readonly enableCommandUris?: boolean;
}
Expand Down Expand Up @@ -137,6 +139,10 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget {
this.ready.resolve();
});
this.toDispose.push(subscription);
this.toDispose.push(this.on(WebviewMessageChannels.doUpdateState, (state: any) => {
this.state = state;
}));
this.toDispose.push(this.on(WebviewMessageChannels.doReload, () => this.reload()));
this.toDispose.push(this.on(WebviewMessageChannels.loadResource, (entry: any) => {
const rawPath = entry.path;
const normalizedPath = decodeURIComponent(rawPath);
Expand Down Expand Up @@ -300,15 +306,14 @@ export namespace WebviewWidget {
viewType: string
title: string
options: WebviewPanelOptions
// TODO serialize/revive URIs
contentOptions: WebviewContentOptions
state: any
// TODO: preserve icon class
}
export function compareWebviewContentOptions(a: WebviewContentOptions, b: WebviewContentOptions): boolean {
return a.enableCommandUris === b.enableCommandUris
&& a.allowScripts === b.allowScripts &&
ArrayExt.shallowEqual(a.localResourceRoots || [], b.localResourceRoots || [], (uri, uri2) => uri.toString() === uri2.toString()) &&
ArrayExt.shallowEqual(a.localResourceRoots || [], b.localResourceRoots || [], (uri, uri2) => uri === uri2) &&
ArrayExt.shallowEqual(a.portMapping || [], b.portMapping || [], (m, m2) =>
m.extensionHostPort === m2.extensionHostPort && m.webviewPort === m2.webviewPort
);
Expand Down
90 changes: 32 additions & 58 deletions packages/plugin-ext/src/main/browser/webviews-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,16 @@ import { ThemeRulesService } from './webview/theme-rules-service';
import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable';
import { ViewColumnService } from './view-column-service';
import { WidgetManager } from '@theia/core/lib/browser/widget-manager';
import { HostedPluginSupport } from '../../hosted/browser/hosted-plugin';
import { JSONExt } from '@phosphor/coreutils/lib/json';
import { Mutable } from '@theia/core/lib/common/types';
import { HostedPluginSupport } from '../../hosted/browser/hosted-plugin';

export class WebviewsMainImpl implements WebviewsMain, Disposable {

private readonly revivers = new Set<string>();
private readonly proxy: WebviewsExt;
protected readonly shell: ApplicationShell;
protected readonly widgets: WidgetManager;
protected readonly pluginService: HostedPluginSupport;
protected readonly viewColumnService: ViewColumnService;
protected readonly keybindingRegistry: KeybindingRegistry;
protected readonly themeService = ThemeService.get();
Expand All @@ -49,20 +49,10 @@ export class WebviewsMainImpl implements WebviewsMain, Disposable {
this.keybindingRegistry = container.get(KeybindingRegistry);
this.viewColumnService = container.get(ViewColumnService);
this.widgets = container.get(WidgetManager);
const pluginService = container.get(HostedPluginSupport);
this.pluginService = container.get(HostedPluginSupport);
this.toDispose.push(this.shell.onDidChangeActiveWidget(() => this.updateViewStates()));
this.toDispose.push(this.shell.onDidChangeCurrentWidget(() => this.updateViewStates()));
this.toDispose.push(this.viewColumnService.onViewColumnChanged(() => this.updateViewStates()));
this.toDispose.push(this.widgets.onDidCreateWidget(({ factoryId, widget }) => {
if (factoryId === WebviewWidget.FACTORY_ID && widget instanceof WebviewWidget) {
const restoreState = widget.restoreState.bind(widget);
widget.restoreState = async oldState => {
restoreState(oldState);
await pluginService.activateByEvent(`onWebviewPanel:${widget.viewType}`);
this.restoreWidget(widget);
};
}
}));
}

dispose(): void {
Expand All @@ -80,17 +70,23 @@ export class WebviewsMainImpl implements WebviewsMain, Disposable {
this.hookWebview(view);
view.viewType = viewType;
view.title.label = title;
const { enableFindWidget, retainContextWhenHidden, enableScripts, ...contentOptions } = options;
const { enableFindWidget, retainContextWhenHidden, enableScripts, localResourceRoots, ...contentOptions } = options;
view.options = { enableFindWidget, retainContextWhenHidden };
view.setContentOptions({ allowScripts: enableScripts, ...contentOptions });
view.setContentOptions({
allowScripts: enableScripts,
localResourceRoots: localResourceRoots && localResourceRoots.map(root => root.toString()),
...contentOptions
});
this.addOrReattachWidget(panelId, showOptions);
}

protected hookWebview(view: WebviewWidget): void {
const handle = view.identifier.id;
const toDisposeOnClose = new DisposableCollection();
const toDisposeOnLoad = new DisposableCollection();

view.eventDelegate = {
// TODO review callbacks
onMessage: m => {
this.proxy.$onMessage(handle, m);
},
Expand Down Expand Up @@ -121,12 +117,14 @@ export class WebviewsMainImpl implements WebviewsMain, Disposable {
}));
}
};
this.toDispose.push(Disposable.create(() => view.eventDelegate = {}));

view.disposed.connect(() => {
toDisposeOnClose.dispose();
this.proxy.$onDidDisposeWebviewPanel(handle);
if (!this.toDispose.disposed) {
this.proxy.$onDidDisposeWebviewPanel(handle);
}
});

this.toDispose.push(view);
toDisposeOnClose.push(Disposable.create(() => this.themeRulesService.setIconPath(handle, undefined)));
}

Expand Down Expand Up @@ -191,11 +189,7 @@ export class WebviewsMainImpl implements WebviewsMain, Disposable {
this.addOrReattachWidget(widget.identifier.id, showOptions);
return;
}
} else if (!widget.options.retainContextWhenHidden) {
// reload content when revealing
widget.reload();
}

if (showOptions.preserveFocus) {
this.shell.revealWidget(widget.id);
} else {
Expand All @@ -221,8 +215,12 @@ export class WebviewsMainImpl implements WebviewsMain, Disposable {

async $setOptions(handle: string, options: WebviewOptions): Promise<void> {
const webview = await this.getWebview(handle);
const { enableScripts, ...contentOptions } = options;
webview.setContentOptions({ allowScripts: enableScripts, ...contentOptions });
const { enableScripts, localResourceRoots, ...contentOptions } = options;
webview.setContentOptions({
allowScripts: enableScripts,
localResourceRoots: localResourceRoots && localResourceRoots.map(root => root.toString()),
...contentOptions
});
}

// tslint:disable-next-line:no-any
Expand All @@ -233,47 +231,23 @@ export class WebviewsMainImpl implements WebviewsMain, Disposable {
}

$registerSerializer(viewType: string): void {
if (this.revivers.has(viewType)) {
throw new Error(`Reviver for ${viewType} already registered`);
}
this.revivers.add(viewType);
this.pluginService.registerWebviewReviver(viewType, widget => this.restoreWidget(widget));
this.toDispose.push(Disposable.create(() => this.$unregisterSerializer(viewType)));
}

$unregisterSerializer(viewType: string): void {
this.revivers.delete(viewType);
this.pluginService.unregisterWebviewReviver(viewType);
}

protected async restoreWidget(widget: WebviewWidget): Promise<void> {
const viewType = widget.viewType;
if (!this.revivers.has(viewType)) {
widget.setHTML(this.getDeserializationFailedContents(viewType));
return;
}
try {
this.hookWebview(widget);
const handle = widget.identifier.id;
const title = widget.title.label;
const state = widget.state;
const options = widget.options;
this.viewColumnService.updateViewColumns();
const position = this.viewColumnService.getViewColumn(widget.id) || 0;
await this.proxy.$deserializeWebviewPanel(handle, viewType, title, state, position, options);
} catch (e) {
widget.setHTML(this.getDeserializationFailedContents(viewType));
console.error('Failed to restore the webview', e);
}
}

protected getDeserializationFailedContents(viewType: string): string {
return `<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-type" content="text/html;charset=UTF-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none';">
</head>
<body>An error occurred while restoring view:${viewType}</body>
</html>`;
this.hookWebview(widget);
const handle = widget.identifier.id;
const title = widget.title.label;
const state = widget.state;
const options = widget.options;
this.viewColumnService.updateViewColumns();
const position = this.viewColumnService.getViewColumn(widget.id) || 0;
await this.proxy.$deserializeWebviewPanel(handle, widget.viewType, title, state, position, options);
}

protected readonly updateViewStates = debounce(() => {
Expand Down

0 comments on commit 17f56a2

Please sign in to comment.