@theia/core
Version:
Theia is a cloud & desktop IDE framework implemented in TypeScript.
400 lines (359 loc) • 16.6 kB
text/typescript
// *****************************************************************************
// Copyright (C) 2017 TypeFox 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-only WITH Classpath-exception-2.0
// *****************************************************************************
import { injectable, inject, named } from 'inversify';
import { Widget } from '@phosphor/widgets';
import { FrontendApplication } from '../frontend-application';
import { WidgetManager, WidgetConstructionOptions } from '../widget-manager';
import { StorageService } from '../storage-service';
import { ILogger } from '../../common/logger';
import { CommandContribution, CommandRegistry, Command } from '../../common/command';
import { ThemeService } from '../theming';
import { ContributionProvider } from '../../common/contribution-provider';
import { ApplicationShell, applicationShellLayoutVersion, ApplicationShellLayoutVersion } from './application-shell';
import { CommonCommands } from '../common-frontend-contribution';
import { WindowService } from '../window/window-service';
import { StopReason } from '../../common/frontend-application-state';
import { isFunction, isObject, MaybePromise } from '../../common';
/**
* A contract for widgets that want to store and restore their inner state, between sessions.
*/
export interface StatefulWidget {
/**
* Called on unload to store the inner state. Returns 'undefined' if the widget cannot be stored.
*/
storeState(): object | undefined;
/**
* Called when the widget got created by the storage service
*/
restoreState(oldState: object): void;
}
export namespace StatefulWidget {
export function is(arg: unknown): arg is StatefulWidget {
return isObject<StatefulWidget>(arg) && isFunction(arg.storeState) && isFunction(arg.restoreState);
}
}
export interface WidgetDescription {
constructionOptions: WidgetConstructionOptions,
innerWidgetState?: string | object
}
export interface ApplicationShellLayoutMigrationContext {
/**
* A resolved version of a current layout.
*/
layoutVersion: number
/**
* A layout to be inflated.
*/
layout: ApplicationShell.LayoutData
/**
* A parent widget is to be inflated. `undefined` if the application shell
*/
parent?: Widget
}
export interface ApplicationShellLayoutMigrationError extends Error {
code: 'ApplicationShellLayoutMigrationError'
}
export namespace ApplicationShellLayoutMigrationError {
const code: ApplicationShellLayoutMigrationError['code'] = 'ApplicationShellLayoutMigrationError';
export function create(message?: string): ApplicationShellLayoutMigrationError {
return Object.assign(new Error(
`Could not migrate layout to version ${applicationShellLayoutVersion}.` + (message ? '\n' + message : '')
), { code });
}
export function is(error: Error | undefined): error is ApplicationShellLayoutMigrationError {
return !!error && 'code' in error && error['code'] === code;
}
}
export const ApplicationShellLayoutMigration = Symbol('ApplicationShellLayoutMigration');
export interface ApplicationShellLayoutMigration {
/**
* A target migration version.
*/
readonly layoutVersion: ApplicationShellLayoutVersion;
/**
* A migration can transform layout before it will be inflated.
*
* @throws `ApplicationShellLayoutMigrationError` if a layout cannot be migrated,
* in this case the default layout will be initialized.
*/
onWillInflateLayout?(context: ApplicationShellLayoutMigrationContext): MaybePromise<void>;
/**
* A migration can transform the given description before it will be inflated.
*
* @returns a migrated widget description, or `undefined`
* @throws `ApplicationShellLayoutMigrationError` if a widget description cannot be migrated,
* in this case the default layout will be initialized.
*/
onWillInflateWidget?(desc: WidgetDescription, context: ApplicationShellLayoutMigrationContext): MaybePromise<WidgetDescription | undefined>;
}
export const RESET_LAYOUT = Command.toLocalizedCommand({
id: 'reset.layout',
category: CommonCommands.VIEW_CATEGORY,
label: 'Reset Workbench Layout'
}, 'theia/core/resetWorkbenchLayout', CommonCommands.VIEW_CATEGORY_KEY);
()
export class ShellLayoutRestorer implements CommandContribution {
protected storageKey = 'layout';
protected shouldStoreLayout: boolean = true;
(ContributionProvider) (ApplicationShellLayoutMigration) protected readonly migrations: ContributionProvider<ApplicationShellLayoutMigration>;
(WindowService) protected readonly windowService: WindowService;
(ThemeService) protected readonly themeService: ThemeService;
constructor(
protected widgetManager: WidgetManager,
(ILogger) protected logger: ILogger,
(StorageService) protected storageService: StorageService) { }
(WidgetManager) registerCommands(commands: CommandRegistry): void {
commands.registerCommand(RESET_LAYOUT, {
execute: async () => this.resetLayout()
});
}
protected async resetLayout(): Promise<void> {
if (await this.windowService.isSafeToShutDown(StopReason.Reload)) {
this.logger.info('>>> Resetting layout...');
this.shouldStoreLayout = false;
this.storageService.setData(this.storageKey, undefined);
this.themeService.reset();
this.logger.info('<<< The layout has been successfully reset.');
this.windowService.reload();
}
}
storeLayout(app: FrontendApplication): void {
if (this.shouldStoreLayout) {
try {
this.logger.info('>>> Storing the layout...');
const layoutData = app.shell.getLayoutData();
const serializedLayoutData = this.deflate(layoutData);
this.storageService.setData(this.storageKey, serializedLayoutData);
this.logger.info('<<< The layout has been successfully stored.');
} catch (error) {
this.storageService.setData(this.storageKey, undefined);
this.logger.error('Error during serialization of layout data', error);
}
}
}
async restoreLayout(app: FrontendApplication): Promise<boolean> {
this.logger.info('>>> Restoring the layout state...');
const serializedLayoutData = await this.storageService.getData<string>(this.storageKey);
if (serializedLayoutData === undefined) {
this.logger.info('<<< Nothing to restore.');
return false;
}
const layoutData = await this.inflate(serializedLayoutData);
await app.shell.setLayoutData(layoutData);
this.logger.info('<<< The layout has been successfully restored.');
return true;
}
protected isWidgetProperty(propertyName: string): boolean {
return propertyName === 'widget';
}
protected isWidgetsProperty(propertyName: string): boolean {
return propertyName === 'widgets';
}
/**
* Turns the layout data to a string representation.
*/
protected deflate(data: object): string {
return JSON.stringify(data, (property: string, value) => {
if (this.isWidgetProperty(property)) {
const description = this.convertToDescription(value as Widget);
return description;
} else if (this.isWidgetsProperty(property)) {
const descriptions: WidgetDescription[] = [];
for (const widget of (value as Widget[])) {
const description = this.convertToDescription(widget);
if (description) {
descriptions.push(description);
}
}
return descriptions;
}
return value;
});
}
private convertToDescription(widget: Widget): WidgetDescription | undefined {
const desc = this.widgetManager.getDescription(widget);
if (desc) {
if (StatefulWidget.is(widget)) {
const innerState = widget.storeState();
return innerState ? {
constructionOptions: desc,
innerWidgetState: this.deflate(innerState)
} : undefined;
} else {
return {
constructionOptions: desc,
innerWidgetState: undefined
};
}
}
}
/**
* Creates the layout data from its string representation.
*/
protected async inflate(layoutData: string): Promise<ApplicationShell.LayoutData> {
const parseContext = new ShellLayoutRestorer.ParseContext();
const layout = this.parse<ApplicationShell.LayoutData>(layoutData, parseContext);
const layoutVersion = Number(layout.version);
if (typeof layoutVersion !== 'number' || Number.isNaN(layoutVersion)) {
throw new Error('could not resolve a layout version');
}
if (layoutVersion !== applicationShellLayoutVersion) {
if (layoutVersion < applicationShellLayoutVersion) {
console.warn(`Layout version ${layoutVersion} is behind current layout version ${applicationShellLayoutVersion}, trying to migrate...`);
} else {
console.warn(`Layout version ${layoutVersion} is ahead current layout version ${applicationShellLayoutVersion}, trying to load anyway...`);
}
console.info(`Please use '${RESET_LAYOUT.label}' command if the layout looks bogus.`);
}
const migrations = this.migrations.getContributions()
.filter(m => m.layoutVersion > layoutVersion && m.layoutVersion <= applicationShellLayoutVersion)
.sort((m, m2) => m.layoutVersion - m2.layoutVersion);
if (migrations.length) {
console.info(`Found ${migrations.length} migrations from layout version ${layoutVersion} to version ${applicationShellLayoutVersion}, migrating...`);
}
const context = { layout, layoutVersion, migrations };
await this.fireWillInflateLayout(context);
await parseContext.inflate(context);
return layout;
}
protected async fireWillInflateLayout(context: ShellLayoutRestorer.InflateContext): Promise<void> {
for (const migration of context.migrations) {
if (migration.onWillInflateLayout) {
// don't catch exceptions, if one migration fails all should fail.
await migration.onWillInflateLayout(context);
}
}
}
protected parse<T>(layoutData: string, parseContext: ShellLayoutRestorer.ParseContext): T {
return JSON.parse(layoutData, (property: string, value) => {
if (this.isWidgetsProperty(property)) {
const widgets = parseContext.filteredArray();
const descs = (value as WidgetDescription[]);
for (let i = 0; i < descs.length; i++) {
parseContext.push(async context => {
widgets[i] = await this.convertToWidget(descs[i], context);
});
}
return widgets;
} else if (isObject(value) && !Array.isArray(value)) {
const copy: Record<string, unknown> = {};
for (const p in value) {
if (this.isWidgetProperty(p)) {
parseContext.push(async context => {
copy[p] = await this.convertToWidget(value[p] as WidgetDescription, context);
});
} else {
copy[p] = value[p];
}
}
return copy;
}
return value;
});
}
protected async fireWillInflateWidget(desc: WidgetDescription, context: ShellLayoutRestorer.InflateContext): Promise<WidgetDescription> {
for (const migration of context.migrations) {
if (migration.onWillInflateWidget) {
// don't catch exceptions, if one migration fails all should fail.
const migrated = await migration.onWillInflateWidget(desc, context);
if (migrated) {
if (isObject(migrated.innerWidgetState)) {
// in order to inflate nested widgets
migrated.innerWidgetState = JSON.stringify(migrated.innerWidgetState);
}
desc = migrated;
}
}
}
return desc;
}
protected async convertToWidget(desc: WidgetDescription, context: ShellLayoutRestorer.InflateContext): Promise<Widget | undefined> {
if (!desc.constructionOptions) {
return undefined;
}
try {
desc = await this.fireWillInflateWidget(desc, context);
const widget = await this.widgetManager.getOrCreateWidget(desc.constructionOptions.factoryId, desc.constructionOptions.options);
if (StatefulWidget.is(widget) && desc.innerWidgetState !== undefined) {
try {
let oldState: object;
if (typeof desc.innerWidgetState === 'string') {
const parseContext = new ShellLayoutRestorer.ParseContext();
oldState = this.parse(desc.innerWidgetState, parseContext);
await parseContext.inflate({ ...context, parent: widget });
} else {
oldState = desc.innerWidgetState;
}
widget.restoreState(oldState);
} catch (e) {
if (ApplicationShellLayoutMigrationError.is(e)) {
throw e;
}
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)) {
throw e;
}
this.logger.warn(`Couldn't restore widget for ${desc.constructionOptions.factoryId}. Error: ${e} `);
return undefined;
}
}
}
export namespace ShellLayoutRestorer {
export class ParseContext {
protected readonly toInflate: Inflate[] = [];
protected readonly toFilter: Widgets[] = [];
/**
* Returns an array, which will be filtered from undefined elements
* after resolving promises, that create widgets.
*/
filteredArray(): Widgets {
const array: Widgets = [];
this.toFilter.push(array);
return array;
}
push(toInflate: Inflate): void {
this.toInflate.push(toInflate);
}
async inflate(context: InflateContext): Promise<void> {
const pending: Promise<void>[] = [];
while (this.toInflate.length) {
pending.push(this.toInflate.pop()!(context));
}
await Promise.all(pending);
if (this.toFilter.length) {
this.toFilter.forEach(array => {
for (let i = 0; i < array.length; i++) {
if (array[i] === undefined) {
array.splice(i--, 1);
}
}
});
}
}
}
export type Widgets = (Widget | undefined)[];
export type Inflate = (context: InflateContext) => Promise<void>;
export interface InflateContext extends ApplicationShellLayoutMigrationContext {
readonly migrations: ApplicationShellLayoutMigration[];
}
}