/******************************************************************************** * 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 WITH Classpath-exception-2.0 ********************************************************************************/ import { inject, injectable, named } from 'inversify'; import { Widget } from '@phosphor/widgets'; import { WidgetConstructionOptions, WidgetManager } from './widget-manager'; import { StorageService } from '@gedit/storage'; import { Logger } from '@gedit/utils'; import { Command, CommandContribution, CommandRegistry } from '@gedit/command'; import { ThemeService } from '@gedit/theme'; import { ContributionProvider } from '@gedit/utils'; import { MaybePromise } from '@gedit/utils'; import { StatefulWidget } from '../widgets'; import { ApplicationShell, applicationShellLayoutVersion, ApplicationShellLayoutVersion } from './application-shell'; import { nls } from '@gedit/nls'; 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; /** * 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; } export const RESET_LAYOUT: Command = { id: 'reset.layout', category: 'View', label: nls.localize('view.resetLayout', 'Reset Workbench Layout') }; @injectable() export class ShellLayoutRestorer implements CommandContribution { protected storageKey = 'layout'; protected shouldStoreLayout: boolean = true; @inject(ContributionProvider) @named(ApplicationShellLayoutMigration) protected readonly migrations: ContributionProvider; @inject(ApplicationShell) protected readonly shell: ApplicationShell; constructor( @inject(WidgetManager) protected widgetManager: WidgetManager, @inject(Logger) protected logger: Logger, @inject(StorageService) protected storageService: StorageService) { } registerCommands(commands: CommandRegistry): void { commands.registerCommand(RESET_LAYOUT, { execute: async () => this.resetLayout() }); } storeLayout(): void { if (this.shouldStoreLayout) { try { this.logger.info('>>> Storing the layout...'); const layoutData = this.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(): Promise { this.logger.info('>>> Restoring the layout state...'); const serializedLayoutData = await this.storageService.getData(this.storageKey); if (serializedLayoutData === undefined) { this.logger.info('<<< Nothing to restore.'); return false; } const layoutData = await this.inflate(serializedLayoutData); await this.shell.setLayoutData(layoutData); this.logger.info('<<< The layout has been successfully restored.'); return true; } async resetLayout(): Promise { this.logger.info('>>> Resetting layout...'); this.shouldStoreLayout = false; this.storageService.setData(this.storageKey, undefined); ThemeService.get().reset(); // Theme service cannot use DI, so the current theme ID is stored elsewhere. Hence the explicit reset. this.logger.info('<<< The layout has been successfully reset.'); window.location.reload(); } 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; }); } /** * Creates the layout data from its string representation. */ protected async inflate(layoutData: string): Promise { const parseContext = new ShellLayoutRestorer.ParseContext(); const layout = this.parse(layoutData, parseContext); // eslint-disable-next-line @typescript-eslint/no-explicit-any let layoutVersion: number | any; try { layoutVersion = 'version' in layout && Number(layout.version); } catch { /* no-op */ } 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 { 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(layoutData: string, parseContext: ShellLayoutRestorer.ParseContext): T { return JSON.parse(layoutData, (property: string, value) => { if (this.isWidgetsProperty(property)) { const widgets: (Widget | undefined)[] = []; 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 (value && typeof value === 'object' && !Array.isArray(value)) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const copy: any = {}; for (const p in value) { if (this.isWidgetProperty(p)) { parseContext.push(async context => { copy[p] = await this.convertToWidget(value[p], context); }); } else { copy[p] = value[p]; } } return copy; } return value; }); } protected async fireWillInflateWidget(desc: WidgetDescription, context: ShellLayoutRestorer.InflateContext): Promise { 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 (migrated.innerWidgetState && typeof migrated.innerWidgetState !== 'string') { // 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 { 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; } } private convertToDescription(widget: Widget): WidgetDescription | undefined { const desc = this.widgetManager.getDescription(widget); if (desc) { let innerState = undefined; if (StatefulWidget.is(widget)) { innerState = widget.storeState(); } return { constructionOptions: desc, innerWidgetState: innerState && this.deflate(innerState) }; } } } export namespace ShellLayoutRestorer { export class ParseContext { protected readonly toInflate: Inflate[] = []; push(toInflate: Inflate): void { this.toInflate.push(toInflate); } async inflate(context: InflateContext): Promise { const pending: Promise[] = []; while (this.toInflate.length) { pending.push(this.toInflate.pop()!(context)); } await Promise.all(pending); } } export type Inflate = (context: InflateContext) => Promise; export interface InflateContext extends ApplicationShellLayoutMigrationContext { readonly migrations: ApplicationShellLayoutMigration[]; } }