Skip to content
Draft
Show file tree
Hide file tree
Changes from 12 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
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.

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 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.
}
Comment on lines +11 to +16
Copy link
Collaborator Author

@brichet brichet Oct 27, 2025

Choose a reason for hiding this comment

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

It looks like this prevents the down area to be restored, at least the log panel.
Overriding the restore function is required AFAIK, to prevent the main area widget to be tracked, and therefore to be restored. Otherwise, loading the page calls a reload of the page (in a new tab) and this can be infinite, because it tries to restore the main area widget.

record-2025-10-27_11.50.22.webm

Not sure how we could handle it yet.

}
Loading
Loading