@jupyterlab/application
Version:
JupyterLab - Application
554 lines • 19.9 kB
JavaScript
/* -----------------------------------------------------------------------------
| Copyright (c) Jupyter Development Team.
| Distributed under the terms of the Modified BSD License.
|----------------------------------------------------------------------------*/
import { JSONExt, PromiseDelegate, Token } from '@lumino/coreutils';
import { AttachedProperty } from '@lumino/properties';
/**
* The layout restorer token.
*/
export const ILayoutRestorer = new Token('@jupyterlab/application:ILayoutRestorer', 'A service providing application layout restoration functionality. Use this to have your activities restored across page loads.');
/**
* The data connector key for restorer data.
*/
const KEY = 'layout-restorer:data';
/**
* The default implementation of a layout restorer.
*
* #### Notes
* The lifecycle for state restoration is subtle. The sequence of events is:
*
* 1. The layout restorer plugin is instantiated and makes a `fetch` call to
* the data connector that stores the layout restoration data. The `fetch`
* call returns a promise that resolves in step 6, below.
*
* 2. Other plugins that care about state restoration require the layout
* restorer as a dependency.
*
* 3. As each load-time plugin initializes (which happens before the front-end
* application has `started`), it instructs the layout restorer whether
* the restorer ought to `restore` its widgets by passing in its widget
* tracker.
* Alternatively, a plugin that does not require its own widget tracker
* (because perhaps it only creates a single widget, like a command palette),
* can simply `add` its widget along with a persistent unique name to the
* layout restorer so that its layout state can be restored when the lab
* application restores.
*
* 4. After all the load-time plugins have finished initializing, the front-end
* application `started` promise will resolve. This is the `first`
* promise that the layout restorer waits for. By this point, all of the
* plugins that care about restoration will have instructed the layout
* restorer to `restore` their widget trackers.
*
* 5. The layout restorer will then instruct each plugin's widget tracker
* to restore its state and reinstantiate whichever widgets it wants. The
* tracker returns a promise to the layout restorer that resolves when it
* has completed restoring the tracked widgets it cares about.
*
* 6. As each widget tracker finishes restoring the widget instances it cares
* about, it resolves the promise that was returned to the layout restorer
* (in step 5). After all of the promises that the restorer is awaiting have
* settled, the restorer then resolves the outstanding `fetch` promise
* (from step 1) and hands off a layout state object to the application
* shell's `restoreLayout` method for restoration.
*
* 7. Once the application shell has finished restoring the layout, the
* JupyterLab application's `restored` promise is resolved.
*
* Of particular note are steps 5 and 6: since data restoration of plugins
* is accomplished by executing commands, the command that is used to restore
* the data of each plugin must return a promise that only resolves when the
* widget has been created and added to the plugin's widget tracker.
*/
export class LayoutRestorer {
/**
* Create a layout restorer.
*/
constructor(options) {
this._deferred = new Array();
this._deferredMainArea = null;
this._firstDone = false;
this._promisesDone = false;
this._promises = [];
this._restored = new PromiseDelegate();
this._trackers = new Set();
this._widgets = new Map();
this._mode = 'multiple-document';
this._connector = options.connector;
this._first = options.first;
this._registry = options.registry;
if (options.mode) {
this._mode = options.mode;
}
void this._first
.then(() => {
this._firstDone = true;
})
.then(() => Promise.all(this._promises))
.then(() => {
this._promisesDone = true;
// Release the tracker set.
this._trackers.clear();
})
.then(() => {
this._restored.resolve(void 0);
});
}
/**
* Whether full layout restoration is deferred and is currently incomplete.
*
* #### Notes
* This flag is useful for tracking when the application has started in
* 'single-document' mode and the main area has not yet been restored.
*/
get isDeferred() {
return this._deferred.length > 0;
}
/**
* A promise resolved when the layout restorer is ready to receive signals.
*/
get restored() {
return this._restored.promise;
}
/**
* Add a widget to be tracked by the layout restorer.
*/
add(widget, name) {
Private.nameProperty.set(widget, name);
this._widgets.set(name, widget);
widget.disposed.connect(this._onWidgetDisposed, this);
}
/**
* Fetch the layout state for the application.
*
* #### Notes
* Fetching the layout relies on all widget restoration to be complete, so
* calls to `fetch` are guaranteed to return after restoration is complete.
*/
async fetch() {
var _a;
const blank = {
fresh: true,
mainArea: null,
downArea: null,
leftArea: null,
rightArea: null,
topArea: null,
relativeSizes: null
};
const layout = this._connector.fetch(KEY);
try {
const [data] = await Promise.all([layout, this.restored]);
if (!data) {
return blank;
}
const { main, down, left, right, relativeSizes, top } = data;
// If any data exists, then this is not a fresh session.
const fresh = false;
// Rehydrate main area.
let mainArea = null;
if (this._mode === 'multiple-document') {
mainArea = this._rehydrateMainArea(main);
}
else {
this._deferredMainArea = main;
}
// Rehydrate down area.
const downArea = this._rehydrateDownArea(down);
// Rehydrate left area.
const leftArea = this._rehydrateSideArea(left);
// Rehydrate right area.
const rightArea = this._rehydrateSideArea(right);
return {
fresh,
mainArea,
downArea,
leftArea,
rightArea,
relativeSizes: relativeSizes || null,
topArea: (_a = top) !== null && _a !== void 0 ? _a : null
};
}
catch (error) {
return blank;
}
}
/**
* Restore the widgets of a particular widget tracker.
*
* @param tracker - The widget tracker whose widgets will be restored.
*
* @param options - The restoration options.
*/
async restore(tracker, options) {
if (this._firstDone) {
throw new Error('restore() must be called before `first` has resolved.');
}
const { namespace } = tracker;
if (this._trackers.has(namespace)) {
throw new Error(`The tracker "${namespace}" is already restored.`);
}
const { args, command, name, when } = options;
// Add the tracker to the private trackers collection.
this._trackers.add(namespace);
// Whenever a new widget is added to the tracker, record its name.
tracker.widgetAdded.connect((_, widget) => {
const widgetName = name(widget);
if (widgetName) {
this.add(widget, `${namespace}:${widgetName}`);
}
}, this);
// Whenever a widget is updated, get its new name.
tracker.widgetUpdated.connect((_, widget) => {
const widgetName = name(widget);
if (widgetName) {
const name = `${namespace}:${widgetName}`;
Private.nameProperty.set(widget, name);
this._widgets.set(name, widget);
}
});
const first = this._first;
if (this._mode == 'multiple-document') {
const promise = tracker
.restore({
args: args || (() => JSONExt.emptyObject),
command,
connector: this._connector,
name,
registry: this._registry,
when: when ? [first].concat(when) : first
})
.catch(error => {
console.error(error);
});
this._promises.push(promise);
return promise;
}
tracker.defer({
args: args || (() => JSONExt.emptyObject),
command,
connector: this._connector,
name,
registry: this._registry,
when: when ? [first].concat(when) : first
});
this._deferred.push(tracker);
}
/**
* Restore the application layout if its restoration has been deferred.
*
* @returns - the rehydrated main area.
*/
async restoreDeferred() {
if (!this.isDeferred) {
return null;
}
// Empty the deferred list and wait for all trackers to restore.
const wait = Promise.resolve();
const promises = this._deferred.map(t => wait.then(() => t.restore()));
this._deferred.length = 0;
await Promise.all(promises);
// Rehydrate the main area layout.
return this._rehydrateMainArea(this._deferredMainArea);
}
/**
* Save the layout state for the application.
*/
save(layout) {
// If there are promises that are unresolved, bail.
if (!this._promisesDone) {
const warning = 'save() was called prematurely.';
console.warn(warning);
return Promise.reject(warning);
}
const dehydrated = {};
// Save the cached main area layout if restoration is deferred.
dehydrated.main = this.isDeferred
? this._deferredMainArea
: this._dehydrateMainArea(layout.mainArea);
dehydrated.down = this._dehydrateDownArea(layout.downArea);
dehydrated.left = this._dehydrateSideArea(layout.leftArea);
dehydrated.right = this._dehydrateSideArea(layout.rightArea);
dehydrated.relativeSizes = layout.relativeSizes;
dehydrated.top = { ...layout.topArea };
return this._connector.save(KEY, dehydrated);
}
/**
* Dehydrate a main area description into a serializable object.
*/
_dehydrateMainArea(area) {
if (!area) {
return null;
}
return Private.serializeMain(area);
}
/**
* Rehydrate a serialized main area description object.
*
* #### Notes
* This function consumes data that can become corrupted, so it uses type
* coercion to guarantee the dehydrated object is safely processed.
*/
_rehydrateMainArea(area) {
if (!area) {
return null;
}
return Private.deserializeMain(area, this._widgets);
}
/**
* Dehydrate a down area description into a serializable object.
*/
_dehydrateDownArea(area) {
if (!area) {
return null;
}
const dehydrated = {
size: area.size
};
if (area.currentWidget) {
const current = Private.nameProperty.get(area.currentWidget);
if (current) {
dehydrated.current = current;
}
}
if (area.widgets) {
dehydrated.widgets = area.widgets
.map(widget => Private.nameProperty.get(widget))
.filter(name => !!name);
}
return dehydrated;
}
/**
* Rehydrate a serialized side area description object.
*
* #### Notes
* This function consumes data that can become corrupted, so it uses type
* coercion to guarantee the dehydrated object is safely processed.
*/
_rehydrateDownArea(area) {
var _a;
if (!area) {
return { currentWidget: null, size: 0.0, widgets: null };
}
const internal = this._widgets;
const currentWidget = area.current && internal.has(`${area.current}`)
? internal.get(`${area.current}`)
: null;
const widgets = !Array.isArray(area.widgets)
? null
: area.widgets
.map(name => internal.has(`${name}`) ? internal.get(`${name}`) : null)
.filter(widget => !!widget);
return {
currentWidget: currentWidget,
size: (_a = area.size) !== null && _a !== void 0 ? _a : 0.0,
widgets: widgets
};
}
/**
* Dehydrate a side area description into a serializable object.
*/
_dehydrateSideArea(area) {
if (!area) {
return null;
}
const dehydrated = {
collapsed: area.collapsed,
visible: area.visible
};
if (area.currentWidget) {
const current = Private.nameProperty.get(area.currentWidget);
if (current) {
dehydrated.current = current;
}
}
if (area.widgets) {
dehydrated.widgets = area.widgets
.map(widget => Private.nameProperty.get(widget))
.filter(name => !!name);
}
if (area.widgetStates) {
dehydrated.widgetStates = area.widgetStates;
}
return dehydrated;
}
/**
* Rehydrate a serialized side area description object.
*
* #### Notes
* This function consumes data that can become corrupted, so it uses type
* coercion to guarantee the dehydrated object is safely processed.
*/
_rehydrateSideArea(area) {
var _a, _b;
if (!area) {
return {
collapsed: true,
currentWidget: null,
visible: true,
widgets: null,
widgetStates: {
['null']: {
sizes: null,
expansionStates: null
}
}
};
}
const internal = this._widgets;
const collapsed = (_a = area.collapsed) !== null && _a !== void 0 ? _a : false;
const currentWidget = area.current && internal.has(`${area.current}`)
? internal.get(`${area.current}`)
: null;
const widgets = !Array.isArray(area.widgets)
? null
: area.widgets
.map(name => internal.has(`${name}`) ? internal.get(`${name}`) : null)
.filter(widget => !!widget);
const widgetStates = area.widgetStates;
return {
collapsed,
currentWidget: currentWidget,
widgets: widgets,
visible: (_b = area.visible) !== null && _b !== void 0 ? _b : true,
widgetStates: widgetStates
};
}
/**
* Handle a widget disposal.
*/
_onWidgetDisposed(widget) {
const name = Private.nameProperty.get(widget);
this._widgets.delete(name);
}
}
/*
* A namespace for private data.
*/
var Private;
(function (Private) {
/**
* An attached property for a widget's ID in the serialized restore data.
*/
Private.nameProperty = new AttachedProperty({
name: 'name',
create: owner => ''
});
/**
* Serialize individual areas within the main area.
*/
function serializeArea(area) {
if (!area || !area.type) {
return null;
}
if (area.type === 'tab-area') {
return {
type: 'tab-area',
currentIndex: area.currentIndex,
widgets: area.widgets
.map(widget => Private.nameProperty.get(widget))
.filter(name => !!name)
};
}
return {
type: 'split-area',
orientation: area.orientation,
sizes: area.sizes,
children: area.children
.map(serializeArea)
.filter(area => !!area)
};
}
/**
* Return a dehydrated, serializable version of the main dock panel.
*/
function serializeMain(area) {
const dehydrated = {
dock: (area && area.dock && serializeArea(area.dock.main)) || null
};
if (area) {
if (area.currentWidget) {
const current = Private.nameProperty.get(area.currentWidget);
if (current) {
dehydrated.current = current;
}
}
}
return dehydrated;
}
Private.serializeMain = serializeMain;
/**
* Deserialize individual areas within the main area.
*
* #### Notes
* Because this data comes from a potentially unreliable foreign source, it is
* typed as a `JSONObject`; but the actual expected type is:
* `ITabArea | ISplitArea`.
*
* For fault tolerance, types are manually checked in deserialization.
*/
function deserializeArea(area, names) {
if (!area) {
return null;
}
// Because this data is saved to a foreign data source, its type safety is
// not guaranteed when it is retrieved, so exhaustive checks are necessary.
const type = area.type || 'unknown';
if (type === 'unknown' || (type !== 'tab-area' && type !== 'split-area')) {
console.warn(`Attempted to deserialize unknown type: ${type}`);
return null;
}
if (type === 'tab-area') {
const { currentIndex, widgets } = area;
const hydrated = {
type: 'tab-area',
currentIndex: currentIndex || 0,
widgets: (widgets &&
widgets
.map(widget => names.get(widget))
.filter(widget => !!widget)) ||
[]
};
// Make sure the current index is within bounds.
if (hydrated.currentIndex > hydrated.widgets.length - 1) {
hydrated.currentIndex = 0;
}
return hydrated;
}
const { orientation, sizes, children } = area;
const hydrated = {
type: 'split-area',
orientation: orientation,
sizes: sizes || [],
children: (children &&
children
.map(child => deserializeArea(child, names))
.filter(widget => !!widget)) ||
[]
};
return hydrated;
}
/**
* Return the hydrated version of the main dock panel, ready to restore.
*
* #### Notes
* Because this data comes from a potentially unreliable foreign source, it is
* typed as a `JSONObject`; but the actual expected type is: `IMainArea`.
*
* For fault tolerance, types are manually checked in deserialization.
*/
function deserializeMain(area, names) {
if (!area) {
return null;
}
const name = area.current || null;
const dock = area.dock || null;
return {
currentWidget: (name && names.has(name) && names.get(name)) || null,
dock: dock ? { main: deserializeArea(dock, names) } : null
};
}
Private.deserializeMain = deserializeMain;
})(Private || (Private = {}));
//# sourceMappingURL=layoutrestorer.js.map