Skip to content
1 change: 0 additions & 1 deletion app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,6 @@
"@jupyterlab/apputils-extension:sanitizer",
"@jupyterlab/apputils-extension:sessionDialogs",
"@jupyterlab/apputils-extension:settings",
"@jupyterlab/apputils-extension:state",
"@jupyterlab/apputils-extension:themes",
"@jupyterlab/apputils-extension:themes-palette-menu",
"@jupyterlab/apputils-extension:toolbar-registry",
Expand Down
2 changes: 2 additions & 0 deletions packages/application-extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,13 @@
"@jupyterlab/docmanager": "~4.5.0-beta.1",
"@jupyterlab/docregistry": "~4.5.0-beta.1",
"@jupyterlab/mainmenu": "~4.5.0-beta.1",
"@jupyterlab/notebook": "~4.5.0-beta.1",
"@jupyterlab/rendermime": "~4.5.0-beta.1",
"@jupyterlab/settingregistry": "~4.5.0-beta.1",
"@jupyterlab/translation": "~4.5.0-beta.1",
"@lumino/coreutils": "^2.2.1",
"@lumino/disposable": "^2.1.4",
"@lumino/polling": "^2.1.4",
"@lumino/widgets": "^2.7.1"
},
"devDependencies": {
Expand Down
235 changes: 234 additions & 1 deletion packages/application-extension/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import {
ILabStatus,
ILayoutRestorer,
IRouter,
ITreePathUpdater,
JupyterFrontEnd,
Expand All @@ -29,6 +30,8 @@ import { DocumentWidget } from '@jupyterlab/docregistry';

import { IMainMenu } from '@jupyterlab/mainmenu';

import { NotebookPanel } from '@jupyterlab/notebook';

import {
ILatexTypesetter,
IMarkdownParser,
Expand All @@ -40,6 +43,8 @@ import {

import { ISettingRegistry } from '@jupyterlab/settingregistry';

import { IStateDB, StateDB } from '@jupyterlab/statedb';

import { ITranslator, nullTranslator } from '@jupyterlab/translation';

import {
Expand All @@ -51,6 +56,7 @@ import {
SidePanelPalette,
INotebookPathOpener,
defaultNotebookPathOpener,
NotebookLayoutRestorer,
} from '@jupyter-notebook/application';

import { jupyterIcon } from '@jupyter-notebook/ui-components';
Expand All @@ -63,6 +69,8 @@ import {
IDisposable,
} from '@lumino/disposable';

import { Debouncer } from '@lumino/polling';

import { Menu, Widget } from '@lumino/widgets';

/**
Expand Down Expand Up @@ -129,6 +137,16 @@ namespace CommandIDs {
* Resolve tree path
*/
export const resolveTree = 'application:resolve-tree';

/**
* Load state for the current workspace.
*/
export const loadState = 'application:load-statedb';

/**
* Reset state when loading for the workspace.
*/
export const resetOnLoad = 'application:reset-on-load';
}

/**
Expand Down Expand Up @@ -176,6 +194,54 @@ const info: JupyterFrontEndPlugin<JupyterLab.IInfo> = {
},
};

/**
* The default layout restorer provider.
*/
const layoutRestorer: JupyterFrontEndPlugin<ILayoutRestorer | null> = {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the left and right panels can also be opened on other pages, maybe this plugin should always provide a LayoutRestorer?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I though only the Notebook has side panel, but the tree view also have one indeed. And the header is also expandable for all the view.
To avoid confusion, we'll probably need a workspace file for each view (tree, notebook, console, terminal, file), because each view has its own panels.

Currently the workspace filename is set to nb-default in the state plugin, but we can probably have a dedicated one according to the URL, or the main widget in the shell.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, we could look into that separately, and only support the notebook page for now.

id: '@jupyter-notebook/application-extension:layout',
description: 'Provides the shell layout restorer.',
requires: [IStateDB],
optional: [INotebookShell],
activate: async (
app: JupyterFrontEnd,
state: IStateDB,
notebookShell: INotebookShell | null
) => {
if (!notebookShell) {
return null;
}
const first = app.started;
const registry = app.commands;

const restorer = new NotebookLayoutRestorer({
connector: state,
first,
registry,
});

// Restore the layout when the main widget is loaded.
void notebookShell.mainWidgetLoaded.then(() => {
// Whether to actually restore the layout or not (not for the tree view).
const restoreLayout =
notebookShell.currentWidget instanceof NotebookPanel;

// Call the restorer even if the layout must not be restored, to resolve the
// promise.
void notebookShell.restoreLayout(restorer, restoreLayout).then(() => {
if (restoreLayout) {
notebookShell.layoutModified.connect(() => {
void restorer.save(notebookShell.saveLayout());
});
}
});
});

return restorer;
},
autoStart: true,
provides: ILayoutRestorer,
};

/**
* The logo plugin.
*/
Expand Down Expand Up @@ -491,7 +557,7 @@ const shell: JupyterFrontEndPlugin<INotebookShell> = {
const customLayout = settings.composite['layout'] as any;

// Restore the layout.
void notebookShell.restoreLayout(customLayout);
void notebookShell.restoreLayoutConf(customLayout);
})
.catch((reason) => {
console.error('Fail to load settings for the layout restorer.');
Expand Down Expand Up @@ -532,6 +598,171 @@ const splash: JupyterFrontEndPlugin<ISplashScreen> = {
},
};

/**
* The default state database for storing application state.
*
* #### Notes
* If this extension is loaded with a window resolver, it will automatically add
* state management commands, URL support for `reset`, and workspace auto-saving.
* Otherwise, it will return a simple in-memory state database.
*/
const state: JupyterFrontEndPlugin<IStateDB> = {
id: '@jupyter-notebook/application-extension:state',
description: 'Provides the application state. It is stored per workspaces.',
autoStart: true,
provides: IStateDB,
requires: [IRouter, ITranslator],
activate: (
app: JupyterFrontEnd,
router: IRouter,
translator: ITranslator
) => {
const trans = translator.load('jupyterlab');

let resolved = false;
const { commands, serviceManager } = app;
const { workspaces } = serviceManager;
const workspace = 'nb-default';
const transform = new PromiseDelegate<StateDB.DataTransform>();
const db = new StateDB({ transform: transform.promise });
const save = new Debouncer(async () => {
const id = workspace;
const metadata = { id };
const data = await db.toJSON();
await workspaces.save(id, { data, metadata });
});

// Any time the local state database changes, save the workspace.
db.changed.connect(() => void save.invoke(), db);

commands.addCommand(CommandIDs.loadState, {
label: trans.__('Load state for the current workspace.'),
describedBy: {
args: {
type: 'object',
properties: {
hash: {
type: 'string',
description: trans.__('The URL hash'),
},
path: {
type: 'string',
description: trans.__('The URL path'),
},
search: {
type: 'string',
description: trans.__(
'The URL search string containing query parameters'
),
},
},
},
},
execute: async (args) => {
// Since the command can be executed an arbitrary number of times, make
// sure it is safe to call multiple times.
if (resolved) {
return;
}

try {
const saved = await workspaces.fetch(workspace);

// If this command is called after a reset, the state database
// will already be resolved.
if (!resolved) {
resolved = true;
transform.resolve({ type: 'overwrite', contents: saved.data });
}
} catch {
console.warn(`Fetching workspace "${workspace}" failed.`);

// If the workspace does not exist, cancel the data transformation
// and save a workspace with the current user state data.
if (!resolved) {
resolved = true;
transform.resolve({ type: 'cancel', contents: null });
}
}

// After the state database has finished loading, save it.
await save.invoke();
},
});

commands.addCommand(CommandIDs.resetOnLoad, {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wondering if this command should be exposed somewhere in the UI? Like in JupyterLab under the Workspaces menu.

Users would then have a way to reset the UI to the defaults and clean things up.

It could be part of the command palette or the menu, or both.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, sounds good. It could be in the file menu, like in lab.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually we should probably create another command (reset) to expose it to the UI. This one checks the URL to find a reset in the query parameters.

label: trans.__('Reset state when loading for the workspace.'),
describedBy: {
args: {
type: 'object',
properties: {
hash: {
type: 'string',
description: trans.__('The URL hash'),
},
path: {
type: 'string',
description: trans.__('The URL path'),
},
search: {
type: 'string',
description: trans.__(
'The URL search string containing query parameters'
),
},
},
},
},
execute: (args) => {
const { hash, path, search } = args;
const query = URLExt.queryStringToObject((search as string) || '');
const reset = 'reset' in query;

if (!reset) {
return;
}

// If the state database has already been resolved, resetting is
// impossible without reloading.
if (resolved) {
return router.reload();
}

// Empty the state database.
resolved = true;
transform.resolve({ type: 'clear', contents: null });

// Maintain the query string parameters but remove `reset`.
delete query['reset'];

const url = path + URLExt.objectToQueryString(query) + hash;
const cleared = db.clear().then(() => save.invoke());

// After the state has been reset, navigate to the URL.
void cleared.then(() => {
router.navigate(url);
});

return cleared;
},
});

router.register({
command: CommandIDs.loadState,
pattern: /.?/,
rank: 30, // High priority: 30:100.
});

router.register({
command: CommandIDs.resetOnLoad,
pattern: /(\?reset|&reset)($|&)/,
rank: 20, // High priority: 20:100.
});

return db;
},
};

/**
* The default JupyterLab application status provider.
*/
Expand Down Expand Up @@ -1189,6 +1420,7 @@ const zen: JupyterFrontEndPlugin<void> = {
const plugins: JupyterFrontEndPlugin<any>[] = [
dirty,
info,
layoutRestorer,
logo,
menus,
menuSpacer,
Expand All @@ -1201,6 +1433,7 @@ const plugins: JupyterFrontEndPlugin<any>[] = [
sidePanelVisibility,
shortcuts,
splash,
state,
status,
tabTitle,
title,
Expand Down
1 change: 1 addition & 0 deletions packages/application/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

export * from './app';
export * from './shell';
export * from './layoutrestorer';
export * from './panelhandler';
export * from './pathopener';
export * from './tokens';
14 changes: 14 additions & 0 deletions packages/application/src/layoutrestorer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { LayoutRestorer } from '@jupyterlab/application';
import { WidgetTracker } from '@jupyterlab/apputils';
import { IRestorer } from '@jupyterlab/statedb';
import { Widget } from '@lumino/widgets';

export class NotebookLayoutRestorer extends LayoutRestorer {
// Override the restore function, that adds widget tracker state to the restorer.
async restore(
tracker: WidgetTracker,
options: IRestorer.IOptions<Widget>
): Promise<any> {
// no-op as we don't want to restore widgets, only the layout.
}
}
Loading
Loading