Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
1 change: 1 addition & 0 deletions packages/application-extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"@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
265 changes: 264 additions & 1 deletion packages/application-extension/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@
// Distributed under the terms of the Modified BSD License.

import {
ILabShell,
ILabStatus,
ILayoutRestorer,
IRouter,
ITreePathUpdater,
JupyterFrontEnd,
JupyterFrontEndPlugin,
JupyterLab,
LayoutRestorer,
} from '@jupyterlab/application';

import {
Expand Down Expand Up @@ -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,
NotebookStateDB,
} 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,21 @@ namespace CommandIDs {
* Resolve tree path
*/
export const resolveTree = 'application:resolve-tree';

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

/**
* Reset application state.
*/
export const reset = 'application:reset-state';

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

/**
Expand Down Expand Up @@ -176,6 +199,48 @@ 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.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

There is now one workspace file per view (notebooks, tree, ...), so we can restore the layout for each view.

I tried to change the workspaces directory in the backend to clarify, and have a dedicated directory for Notebook application, without success so far.

I couldn't make it work by replacing

notebook/notebook/app.py

Lines 310 to 311 in fd9c4b4

def _default_workspaces_dir(self) -> str:
return t.cast(str, get_workspaces_dir())

with

    @default("workspaces_dir")
    def _default_workspaces_dir(self) -> str:
        workspaces_dir = Path(jupyter_data_dir()) / "workspaces" / "notebook_app"
        workspaces_dir.mkdir(parents=True, exist_ok=True)
        return t.cast(str, str(workspaces_dir))

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 LayoutRestorer({
connector: state,
first,
registry,
});

// Restore the layout when the main widget is loaded.
void notebookShell.mainWidgetLoaded.then(() => {
// Call the restorer even if the layout must not be restored, to resolve the
// promise.
void notebookShell.restoreLayout(restorer).then(() => {
notebookShell.layoutModified.connect(() => {
void restorer.save(notebookShell.saveLayout() as ILabShell.ILayout);
});
});
});

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

/**
* The logo plugin.
*/
Expand Down Expand Up @@ -491,7 +556,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 +597,202 @@ 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],
optional: [ICommandPalette],
activate: (
app: JupyterFrontEnd,
router: IRouter,
translator: ITranslator,
palette: ICommandPalette | null
) => {
const trans = translator.load('jupyterlab');

let resolved = false;
const { commands, serviceManager } = app;
const { workspaces } = serviceManager;
const workspace = PageConfig.getOption('notebookPage');
const transform = new PromiseDelegate<StateDB.DataTransform>();
const db = new NotebookStateDB({ 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.reset, {
label: trans.__('Reset Application State'),
describedBy: {
args: {
type: 'object',
properties: {
reload: {
type: 'boolean',
description: trans.__(
'Whether to reload the page after resetting'
),
},
},
},
},
execute: async () => {
await db.clear();
await save.invoke();

// Save the current document and reload.
await commands.execute('docmanager:save');
router.reload();
},
});

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;
},
});

if (palette) {
palette.addItem({ category: 'state', command: CommandIDs.reset });
}

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 +1450,7 @@ const zen: JupyterFrontEndPlugin<void> = {
const plugins: JupyterFrontEndPlugin<any>[] = [
dirty,
info,
layoutRestorer,
logo,
menus,
menuSpacer,
Expand All @@ -1201,6 +1463,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 @@ -5,4 +5,5 @@ export * from './app';
export * from './shell';
export * from './panelhandler';
export * from './pathopener';
export * from './statedb';
export * from './tokens';
Loading
Loading